(517)

MyISAM에서 트랜잭션 사용하기

서론MyISAM에서는 트랜잭션을 사용할 수 있을까요? "아뇨, 사용할 수 없습니다." 엥??? 그럼 제목은 어그로임??? 아쉽게도 MyISAM은 트랜잭션을 지원하지 않습니다. MySQL 공식 문서에서도 분명 명시되어있습니다. 더 아쉽게도, 저는 현업에서 아직도 MyISAM 엔진을 사용중이며, InnoDB로의 마이그레이션이 불가능한 상황입니다. 협업 과정에서 데이터베이스 엔진의 마이그레이션을 하지 않으면 안되겠냐는 얘기를 들었고, 마이그레이션이 불가능한 원인을 소통 과정에서 짐작해보자면 기술적인 문제보다는 현재 잘 동작하기에, 그리고 변경에 대한 불안감 등의 심리적인 요인일 듯 합니다. 이런 상황을 베이스로 실제 발생했던 아래 문제들과데이터 정합성이 맞지 않음(CS로 이어짐): 주문은 했는데 캐시..

개발자로서 첫 발표를 마치고

첫 발표를 마치고개발자로서 첫 발표를 무사히(?) 끝마쳤습니다. 포스팅을 작성하면서도 가슴이 벌렁벌렁하네요.. 저는 현업에서 혼자 개발하는 환경에 있다 보니,'내가 잘하고 있는 게 맞을까?' 하는 기술적인 갈증이 늘 있었습니다.그런 저에게 오픈소스는 코드를 통해 전 세계 누구와도 소통할 수 있는 가장 완벽한 소통의 장이자 탈출구가 되어주었습니다.최근, 오픈소스 기여 모임 9기에서 제가 오픈소스에 기여하는 과정에 대해 '오픈소스 기여로 레벨업'이라는 주제로 발표하는 기회를 가졌습니다. 발표에서 전하려 했던 것저는 이 발표를 통해, 단순히 코드를 기여한 경험을 넘어, 다음과 같은 저의 고민과 과정을 전달하고 싶었습니다. 1. 기술적 깊이를 더하기하나의 이슈를 해결하는 것을 넘어, '왜 이 코드가 이렇게..

Claude Code, OOM과 할루시네이션 없이 똑똑하게 사용하는 메모리 최적화 전략

최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코..

Gemini-CLI 실행 속도 개선에 기여하기

서론오픈소스 기여모임 9기가 끝이 났습니다.저는 기여모임 내에서 다양한 오픈소스에 PR을 생성했습니다.nest: 6개의 PRloki: 1개의 PRprisma: 1개의 PR(Merged)gemini-cli: 1개의 PR(Merged)이 중, gemini-cli는 현재 AI를 다루는 능력이 거의 필수 스택으로 자리잡았고, 저에게 가장 친숙한 TypeScript기반이라는 점, 마지막으로 내가 구글에 기여할 수 있다니!!! 와 같은 이유로 기여하기로 했는데요 ㅋㅋ.. 그래서, 이번 포스팅은 gemini-cli의 기여에 대한 포스팅입니다. Gemini-CLI 현 시점 CLI 기반 AI의 양대산맥이라고 한다면, claude code와 gemini-cli가 대표적인데요. 여러 차이가 있겠지만, 오픈소스 성격을 띠..

여러 Claude Max 계정에서 Claude Code 실행 환경 세팅하기

서론오늘 사내에서 AI 지원 정책이 임시 확정되었습니다. 클로드 코드 맥스, 커서 프로, GPT Plus를 모두 개인 계정으로 지원해주기로 했습니다.기존에 사용하던 AI 구독료가 굳어서 횡재했다~ 라는 생각을 잠시 했지만, 회사 입장에서는 개인용으로 사용하는 것을 꺼려할 수도 있을 것 같아요. 그래서 개인 계정 구독을 취소하지 않기로 했습니다.GPT는 GUI로 사용해서 브라우저를 분리하면 되고, 커서도 프로세스별로 다른 계정을 로그인하도록 구성하면 될 것 같은데, 로컬에서 로그인해서 사용하는 CLI 기반의 클로드 코드는 어떻게 다중 접속을 지원하는지 확인이 필요했습니다. Reddit의 ClaudeAI 커뮤니티ClaudeAI 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요www.reddit.c..

Prisma v6.14.0의 성능 개선에 따른 브레이킹 체인지, 이에 기여한 이야기.

서론 들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다. Prisma는 왜 Type-Safe할까?TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장mag1c.tistory.com 오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니..

Prisma는 왜 Type-Safe할까?

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠) 이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다. PrismaPrisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다..

티스토리 스킨 무료 배포합니다!! 제발 사용해주세요!!

기술 블로그를 3년 가까이 운영하면서, 언젠가는 티스토리 커스텀 스킨을 만들어야지 생각했습니다. 하지만 저는 프론트, 특히 CSS를 잘 다루지 못해서 언젠간 만들어야지 생각만 했던 것 같아요. 드디어!!! 커스텀 스킨을 제작했습니다. 물론 클로드 코드를 사용해서 바이브 코딩으로 만들었습니다. (CSS가 너무 어렵군요..)티스토리 커스텀 스킨을 무료로 배포합니다!!! 제발 사용해주세요!!!(??) 제 블로그 적용되어있는 상태이니 둘러보시고 마음에 드신다면 아래 레포의 가이드를 따라 사용해주시면 되겠습니다. 비개발자분들도 접속하셔서 코드를 ZIP으로 다운로드만 받아서 티스토리에 등록만 하면 사용이 가능합니다. 사용에 어려움이 있으신 분은 댓글 남겨주시면 답변 드리겠습니다. GitHub - mag123c/ti..

[Rate Limit - 3] Fixed Window Counter, Sliding Window Logging, Sliding Window Counter 알고리즘

[목차]1. Rate Limit이란? 설계 시 주의사항2. Token Bucket, Leaky Bucket 알고리즘3. Fixed Window, Sliding Window Logging, Sliding Window Counter 알고리즘 지난 포스팅에선, Bucket 방식의 Token Bucket, Leaky Bucket 알고리즘을 살펴보고, 직접 구현과 테스트를 진행했다.이번 글에선 Window 방식의 알고리즘인 Fixed Window Counter, Sliding Window Logging, Sliding Window Counter을 다룬다. 모든 예제 코드는 아래 Github에서 확인할 수 있다. GitHub - mag123c/rate-limiterContribute to mag123c/rate-..

MyISAM에서 트랜잭션 사용하기

Tech/기타 2025. 10. 10. 10:21
728x90
728x90

서론

MyISAM에서는 트랜잭션을 사용할 수 있을까요?

 

"아뇨, 사용할 수 없습니다."

 

 

엥??? 그럼 제목은 어그로임???

 

 

 

 

 

아쉽게도 MyISAM은 트랜잭션을 지원하지 않습니다. MySQL 공식 문서에서도 분명 명시되어있습니다.

 

더 아쉽게도, 저는 현업에서 아직도 MyISAM 엔진을 사용중이며, InnoDB로의 마이그레이션이 불가능한 상황입니다.

 

협업 과정에서 데이터베이스 엔진의 마이그레이션을 하지 않으면 안되겠냐는 얘기를 들었고, 마이그레이션이 불가능한 원인을 소통 과정에서 짐작해보자면 기술적인 문제보다는 현재 잘 동작하기에, 그리고 변경에 대한 불안감 등의 심리적인 요인일 듯 합니다.

 

이런 상황을 베이스로 실제 발생했던 아래 문제들과

  • 데이터 정합성이 맞지 않음(CS로 이어짐): 주문은 했는데 캐시 사용이 제대로 안되거나 발주서가 기업체에게 도달하지 않음.
  • DX문제: 보상 로직을 예외 처리 구간에서 직접 하나하나 넣어줘야함.

트랜잭션과 유사한 동시성과 데이터 정합성을 지키기 위해 애플리케이션 레벨에서 시도했던 경험,

어쩌면 InnoDB를 사용하지 않는 환경이었기에 얻었던 경험에 대해 기록하고 공유하고자 합니다.

 

 

아래부터 나올 모든 코드는 Typescript로 작성되어있습니다.

 

 

 

MyISAM의 데이터 정합성 문제

다들 아시다시피 MyISAM은 트랜잭션을 지원하지 않습니다.

간단하게, 결제 시 캐시를 차감하고 히스토리를 남기는 로직을 예시로 들어보겠습니다.

async function createOrder(userId: number, amount: number) {
  const rollbackActions: Array<() => Promise<void>> = [];

  try {
    // 1. 포인트 차감
    const user = await prisma.user.update({
      where: { id: userId },
      data: { icash: { decrement: amount } }
    });
    rollbackActions.push(async () => {
      await prisma.user.update({
        where: { id: userId },
        data: { icash: { increment: amount } }
      });
    });

    if (user.icash < 0) {
      throw new Error('잔액 부족');
    }

    // 2. 주문 생성
    const order = await prisma.order.create({
      data: { userId, amount, status: 'PAID' }
    });
    rollbackActions.push(async () => {
      await prisma.order.delete({ where: { id: order.id } });
    });

    // 3. 결제 이력 생성
    await prisma.paymentHistory.create({
      data: { userId, amount, action: 'DEDUCT' }
    });
  } catch (error) {
    // 역순으로 롤백 실행
    while (rollbackActions.length > 0) {
      try {
        const rollback = rollbackActions.pop();
        if (!rollback) break;
        await rollback();      
      } catch(rollbackError) {
        // 보상 로직 실패
        console.error(rollbackError);
      }
    }
    throw error;
  }
}

 

MyISAM은 트랜잭션이 없기 때문에 원자성을 보장하지 않습니다.

따라서 성공 작업들을 모두 개발자가 직접 롤백해줘야겠군요.

 

이정도면, 개발자가 작업하면서 실수로 코드를 뺴먹는 일만 없다면, 문제 없을 것 처럼 보입니다.

실수 방지를 위해 타입스크립트를 활용해서 보완할 수 있습니다.

아래처럼 compensate를 강제한다면 컴파일 단계에서 휴먼 에러도 방지할 수 있을 것 같아요. 훌륭합니다!

interface TransactionStep<TResult = any> {
  name: string;
  execute: () => Promise<TResult>;
  compensate: (result: TResult) => Promise<void> | void;
}

async function createOrder(userId: number, amount: number) {
  // Step 정의: execute와 compensate를 함께 정의하도록 강제
  const steps: TransactionStep[] = [
    {
      name: '포인트_차감',
      execute: async () => {
        const user = await prisma.user.update({
          where: { id: userId },
          data: { icash: { decrement: amount } }
        });

        if (user.icash < 0) {
          throw new Error('잔액 부족');
        }

        return { userId, amount };
      },
      compensate: async (result) => {
        await prisma.user.update({
          where: { id: result.userId },
          data: { icash: { increment: result.amount } }
        });
      }
    },
    // ... 나머지 Step들
  ];
}

 

 

하지만, 롤백 로직만 커스터마이징한다고 해결되는 문제가 아닙니다.

  • 보상에 실패한다면?
  • 동시 요청이 발생한다면?

보상에 실패한다면 어떻게 해야 할까요? 무한 반복? 코드를 아래처럼 바꾸면 될까요?

while (rollbackActions.length > 0) {
  try {
    const rollback = rollbackActions[rollbackActions.length - 1]; // peek
    await rollback();
    rollbackActions.pop(); // 성공 시에만 제거
  } catch(rollbackError) {
    // 계속 재시도... 무한 반복
    await sleep(1000);
  }
}

 

예상되지 않는 상황에 무한정 반복을 하는 것은 올바른 패턴이 아닙니다.

어떤 원인으로 인해 실패했는지 모르는데 무한정 반복하게 되면 어떤 다른 문제들이 더 발생할지 짐작할 수 없기 때문입니다.

 

 

동시 요청을 막기 위해 MyISAM의 Lock을 사용하면 될까요?

MyISAM의 Lock은 InnoDB처럼 레코드 단위의 락이 아닙니다. 테이블 단위의 락은 락을 해제할 때까지 그 누구도 해당 테이블에 접근하지 못하는 것을 의미합니다. 1번 유저의 캐시 차감을 위해 모든 사용자의 정보 변경, 조회 등 모든 요청이 락이 해제될 때 까지 대기하게 되는 문제가 발생합니다.

 

 

 

 

트랜잭션이 없다면 직접 만들자

지금까지 확인한 문제들을 정리하면

  1. 보상 로직 누락 방지(O): TransactionStep 인터페이스로 컴파일 타입에 누락 방지 가능
  2. 보상 실패 처리(X): 무한 재시도는 답이 아님 - 데이터 복구를 위한 시스템(DLQ 등)이 필요
  3. 동시성 제어(X): MyISAM의 테이블 락은 안됨 - 세밀한 동시성 제어를 위한 수단이 필요함

 

 

MyISAM에서 트랜잭션을 제공하지 않기 때문에, 애플리케이션 레벨에서 하나씩 구현하기로 했습니다.

  1. 트랜잭션 컨텍스트: 트랜잭션 관리와 트랜잭션과 유사한 다양한 기능을 지원하는 무언가가 필요함
  2. 원자성: 메시지 큐를 이용한 Step 단위의 실행과 자동 보상
  3. 격리성: Redis 분산락을 이용한 리소스별 동시성 제어
  4. 장애 복구: DLQ로 보상 실패에 대한 격리 및 재시도 인터페이스

 

 

트랜잭션 컨텍스트

트랜잭션을 위해서는 우선 나는 트랜잭션이다!!! 라는 명확한 컨텍스트가 필요했습니다.

 

또한 이 트랜잭션 컨텍스트 내부에는 다음과 같은 정보들이 포함되어야 한다고 생각했습니다.

{
  transactionId: "payment-123",           // 트랜잭션 식별자
  currentStep: 2,                          // 현재 진행 중인 Step 인덱스
  executedSteps: [                         // 완료된 Step들의 결과
    { name: "포인트_차감", result: {...} },
    { name: "주문_생성", result: {...} }
  ],
  status: "in_progress",                   // 트랜잭션 상태
  lockKey: "user:123",                     // 획득한 락 정보
  businessContext: { userId: 123, ... }    // 비즈니스 메타데이터
}

 

 

저는 이 트랜잭션 컨텍스트를 BullMQ의 Job으로 구현했는데요, 이유는 다음과 같습니다.

 

 

1. 중단 지점 재개

실행된 Step은 건너뛰고 중단 지점부터 재실행이 가능합니다.

@Process('execute-transaction')
async handleTransaction(job: Job) {
  const executedSteps = job.data.executedSteps || [];
  const startIndex = executedSteps.length; // 이미 실행된 Step 건너뛰기

  for (let i = startIndex; i < steps.length; i++) {
    const result = await steps[i].execute();
    executedSteps.push({ step: steps[i].name, result });
    await job.updateData({ ...job.data, executedSteps });
  }
}

 

 

2. 트랜잭션 추적 및 상태 조회

Job ID가 트랜잭션 식별자(txid) 역할을 하며, 언제든 상태 조회가 가능합니다.

const status = await transactionManager.getStatus(jobId);
// { status: 'active', progress: 66, currentStep: 2 }

 

 

3. 동기식 API 응답 지원

try {
  // 대부분의 트랜잭션은 3초 내 완료가 됨
  const result = await job.waitUntilFinished(queueEvents, 5000);
  return { status: 'completed', result };
} catch (timeout) {
  // 긴 트랜잭션의 경우 jobId 반환으로 추가 조회(폴링 등)
  return { status: 'processing', jobId };
}

 

waitUntilFinished는 언제 완료될 지 모르기 때문에, timeout과 반드시 결합해서 사용해야 합니다.
저는 정량적으로 측정이 가능한 작업 시간이기 때문에, 타임아웃을 사용했지만 보통 권장하는 패턴은 아니라고합니다.
(문서 참조)

 

 

 

4. 재시도 정책

BullMQ는 일시적 장애일 경우를 대비해 자동 재시도를 구성할 수 있습니다.

await queue.add('transaction', data, {
  attempts: 3,
  backoff: {
    type: 'exponential',
    delay: 2000
  }
});

 

 

 

 

 

원자성

트랜잭션의 원자성을 만족하려면, 모두 커밋되거나 모두 롤백되어야합니다.

@Process('execute-transaction')
async handleTransaction(job: Job) {
  const { steps } = job.data;
  const executionResults: ExecutionResult[] = [];

  try {
    // 1. Step 순차 실행
    for (const stepData of steps) {
      const step = this.stepRegistry.get(stepData.executeFnName);

      this.logger.log(`[${stepData.name}] 실행 중...`);
      const result = await step.execute(stepData.params);

      executionResults.push({
        stepName: stepData.name,
        compensateFnName: stepData.compensateFnName,
        result
      });

      this.logger.log(`[${stepData.name}] 완료`);
    }

    // 2. 모든 Step 성공
    return { status: 'success', results: executionResults };

  } catch (stepError) {
    // 3. Step 실패 → 자동 보상 (역순)
    this.logger.error(`Step 실패, 보상 시작: ${stepError.message}`);

    for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
      try {
        this.logger.log(`[${stepName}] 보상 실행 중...`);

        const compensateFn = this.stepRegistry.get(compensateFnName);
        await compensateFn(result);

        this.logger.log(`[${stepName}] 보상 완료`);

      } catch (compensationError) {
        // 보상 실패 → 에러 기록
        this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);

        // 보상 실패 건은 "장애 복구" 섹션에서 다룰 DLQ로 격리됩니다.
        break;
      }
    }

    throw stepError;
  }
}

 

기본적인 흐름은 다음과 같습니다.

  1. 트랜잭션 컨텍스트 내 각 작업(Step)을 순차 실행
  2. 완료된 스텝들은 혹시 모를 롤백을 위해 기록

 

 

위처럼 원자성을 보장하기 위한 시도도 보상 로직의 실패에 대한 잠재적인 위험은 그대로 남아있기 때문에, 이리저리 고민을 해봤습니다.

애플리케이션 레벨에서 보상을 계속 진행하기 위해서는 데이터베이스와의 통신이 불가피한데, 예기치 못한 상황으로 계속 실패할 수 있습니다. 결국 직접 데이터베이스 엔진에서 수행하는 일련의 동작이 아니기 때문에, 어느정도 수동 개입은 필요하다고 판단했습니다.

 

이는 아래 장애 복구 세션에서 다루도록 하겠습니다.

 

 

 

격리성

앞서 언급했듯이, MyISAM은 레코드 단위 락을 지원하지 않습니다.

// 사용자 1의 결제 처리 중
LOCK TABLES User WRITE, `Order` WRITE;
await processPayment(userId: 1, amount: 1000);
UNLOCK TABLES;

// 사용자 2의 결제 시도
await processPayment(userId: 2, amount: 500);
// 사용자 1의 락이 해제될 때까지 대기

 

위의 코드에서, 사용자 1이 결제를 위해 Lock을 획득했습니다.

이 때 MyISAM에서는 사용자 1의 결제 중 모든 사용자가 대기하는 상황이 발생하게 됩니다.

같은 결제 로직 뿐 아니라 단순 조회까지 차단됩니다.

 

InnoDB라면 당연히 얻을 수 있는 레코드 단위 락과, MVCC같은 잠금 없는 일관된 읽기가 지원되지 않습니다.

 

 

이를 해결하기 위해 Redis로 분산락을 사용해 InnoDB의 레코드 락 처럼 구현해보려고 했습니다.

export class DistributedLockService {
  async acquire(key: string, ttl: number): Promise<RedisLock> {
    const lockKey = `lock:transaction:${key}`;
    const lockValue = this.generateLockId();
    const ttlSeconds = Math.ceil(ttl / 1000);
    
    const result = await this.redis.set(
      lockKey,
      lockValue,
      'EX', ttlSeconds,
      'NX'
    );

    if (result !== 'OK') {
      throw new Error(`락 획득 실패: ${key} (다른 프로세스가 사용 중)`);
    }

    return { key: lockKey, value: lockValue, acquiredAt: Date.now() };
  }
}
@Process('execute-transaction')
async handleTransaction(job: Job) {
  const { steps, lockKey } = job.data;
  const executionResults: ExecutionResult[] = [];
  let lock: RedisLock | null = null;

  try {
    // 1. 분산락 획득
    if (lockKey) {
      lock = await this.lockService.acquire(lockKey, 30000);
      this.logger.log(`분산락 획득: ${lockKey}`);
    }

    // 2. Step 순차 실행
    for (const stepData of steps) {
      const step = this.stepRegistry.get(stepData.executeFnName);
      const result = await step.execute(stepData.params);
      executionResults.push({
        stepName: stepData.name,
        compensateFnName: stepData.compensateFnName,
        result
      });
    }

    return { status: 'success' };

  } catch (stepError) {
    // 3. 보상 트랜잭션
    for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
      try {
        const compensateFn = this.stepRegistry.get(compensateFnName);
        await compensateFn(result);
      } catch (compensationError) {
        this.logger.error(`[${stepName}] 보상 실패`);
        // 보상 실패는 장애 복구 섹션에서 처리
        break;
      }
    }
    throw stepError;

  } finally {
    // 4. 락 해제
    if (lock) {
      await this.lockService.release(lock);
      this.logger.log(`분산락 해제: ${lockKey}`);
    }
  }
}

 

 

1. TTL

TTL을 추가한 이유는, 뭔가 재배포 상황이나 기타 프로세스의 크래시로 인해 락이 영원히 남아있는 것을 고려하여 자동 만료 처리를 위해 구성해두었습니다.

const result = await this.redis.set(
  lockKey,
  lockValue,
  'EX', ttlSeconds,  // TTL
  'NX'
);

 

 

 

2. Lua로 Lock 소유권 검증

다른 프로세스가 혹여 락을 삭제할 수도 있기 때문에, Lua 스크립트를 통해 내 락인지 확인 후 삭제하도록 구성해뒀습니다.

Lua 스크립트는 원자적인 실행을 보장하기 때문에 Race Condition을 방지하고, 내가 획득한 락만 해제가 가능합니다.

async release(lock: RedisLock): Promise<void> {
  const luaScript = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end
  `;

  const result = await this.redis.eval(
    luaScript,
    1,
    lock.key,       // KEYS[1]: "lock:transaction:user:123"
    lock.value      // ARGV[1]: "processA-12345"
  );

  if (result === 0) {
    this.logger.warn(`락 소유권 불일치: ${lock.key}`);
  }
}

 

 

 

3. 멱등키로 중복 요청 방지

분산락은 동시 요청을 제어하지만, 처리가 완료된 후의 중복 요청 처리를 방지하지는 못합니다.

// 최초 결제 요청
POST /api/payment { userId: 123, amount: 10000 }

// 가정: 결제는 성공했지만 네트워크 문제로 클라이언트 응답 못 받음

// 클라이언트는 실패한 줄 알고 재시도 -> 락 해제되어 재결제
POST /api/payment { userId: 123, amount: 10000 }

 

 

멱등키를 구현함으로써, 위와 같은 엣지 케이스에 대해 중복 처리를 방지할 수 있다고 생각했습니다.

(멱등키는 클라이언트에서 생성을 하도록 구성해뒀기 때문에 서버 코드에는 생략되어있습니다.)

 

@Post('payment')
async createPayment(
  @Body() dto: PaymentDto,
  @Headers('idempotency-key') idempotencyKey: string
) {
  // 1. 멱등키 확인 (중복 트랜잭션 방지)
  const existing = await redis.get(`idempotency:${idempotencyKey}`);
  if (existing) return JSON.parse(existing);

  // 2. 분산락 획득 (동시 실행 방지)
  const lock = await lockService.acquire(`user:${dto.userId}`, 30000);

  try {
    // 3. 트랜잭션 실행
    const result = await transactionManager.executeTransaction({
      transactionId: idempotencyKey,
      lockKey: `user:${dto.userId}`,
      steps: [
        { name: '포인트_차감', executeFn: 'deductIcash', ... },
        { name: '주문_생성', executeFn: 'createOrder', ... },
        { name: 'SMS_발송', executeFn: 'sendSMS', ... }
      ]
    });
    
    // 4. 멱등키에 결과 저장
    await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));

    return result;
  } finally {
    // 5. 락 해제
    await lockService.release(lock);
  }
}

 

이렇게, Redis 분산락과 멱등키를 활용해서 MyISAM에서도 활용가능한 격리성을 구현할 수 있었습니다.

InnoDB의 레코드 단위의 락 처럼 사용자별 독립적인 처리가 가능하고, 중복 요청을 완전 차단하며 크래시 안정성을 TTL + Lua로 보완할 수 있었습니다.

 

 

 

 

장애 복구

원자성 섹션에서 언급했듯이, 보상 트랜잭션도 실패할 수 있습니다.

일부 수동 처리는 불가피하다고 판단했기 때문에 현재 Job의 구성과 동일한 DLQ를 구성해 사용했습니다.

@Process('execute-transaction')
async handleTransaction(job: Job) {
  const { steps, lockKey } = job.data;
  const executionResults: ExecutionResult[] = [];
  let lock: RedisLock | null = null;

  try {
    // 1. 분산락 획득
    if (lockKey) {
      lock = await this.lockService.acquire(lockKey, 30000);
    }

    // 2. Step 순차 실행
    for (const stepData of steps) {
      const step = this.stepRegistry.get(stepData.executeFnName);
      const result = await step.execute(stepData.params);

      executionResults.push({
        stepName: stepData.name,
        compensateFnName: stepData.compensateFnName,
        result
      });
    }

    return { status: 'success' };

  } catch (stepError) {
    // 3. 보상 트랜잭션
    const compensationResults = [];

    for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
      try {
        const compensateFn = this.stepRegistry.get(compensateFnName);
        await compensateFn(result);

        compensationResults.push({ stepName, status: 'compensated' });

      } catch (compensationError) {
        // 보상 실패 기록
        compensationResults.push({
          stepName,
          status: 'compensation_failed',
          error: compensationError
        });

        this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
        break;
      }
    }

    // 4. DLQ에 격리
    const hasFailure = compensationResults.some(
      r => r.status === 'compensation_failed'
    );

    if (hasFailure) {
      await this.dlqService.add({
        jobId: job.id!,
        transactionId: job.data.transactionId,
        executedSteps: executionResults,
        compensationResults,
        failedAt: compensationResults.find(r => r.status === 'compensation_failed')?.stepName,
        error: stepError,
        businessContext: job.data.businessContext
      });

      this.logger.error(`DLQ 추가: ${job.id}`);
    }

    throw stepError;

  } finally {
    // 5. 락 해제
    if (lock) {
      await this.lockService.release(lock);
    }
  }
}

 

전체 보상 트랜잭션의 상태를 저장해서, 수동 처리 시 전체 컨텍스트를 파악할 수 있게 DLQ 관리 인터페이스들을 만들 수 있었습니다.

// DLQ 조회
GET /api/dlq

// 재시도 (실패한 보상부터 다시 실행)
POST /api/dlq/retry/:jobId

// 수동 처리 후 제거
POST /api/dlq/resolve/:jobId
// 재시도 로직
async retry(jobId: string): Promise<void> {
  const dlqEntry = await this.get(jobId);

  // 실패한 보상부터 다시 시도
  const failedIndex = dlqEntry.compensationResults.findIndex(
    r => r.status === 'compensation_failed'
  );

  const remainingSteps = dlqEntry.executedSteps.slice(failedIndex);

  for (const step of remainingSteps) {
    const compensateFn = this.stepRegistry.get(step.compensateFnName);
    await compensateFn(step.result);
  }

  // 성공 시 DLQ에서 제거
  await this.remove(jobId);
}

 

 

 

 

 

 

마무리

긍정적인 효과를 정리하자면, MyISAM의 한계를 BullMQ + Redis로 극복하여 유사 트랜잭션을 얻을 수 있게 되었다는 점입니다.

  • DX 개선: 보상 로직 코드를 더이상 일일이 만들지 않고 트랜잭션 컨텍스트화 하면 됨
  • UX 개선: 데이터 부정합 CS가 월 3+a건에서 0~1건으로 줄어듬
  • 운영 효율: DLQ 기반 자동 복구로 수동 작업 불필요

 

하지만, 이 접근법은 결국 트랜잭션은 아니기 때문에 한계가 존재하는데요.

  1. 보상 트랜잭션의 한계: 보상 실패 시 수동 개입 필요
  2. 성능 오버헤드와 운영 복잡도: 분산락(네트워크 I/O)과 메시지 큐 관리 오버헤드 및 DB I/O에 필요한 시스템이 많아짐

 

무에서 유를 창조해야하는데 이정도의 트레이드오프는 어느정도 감수해야하지 않을까 생각은 했지만 정말 이 방법 밖에 없었을까, 조금 더 간소화 할 수 있었을까 하는 생각이 계속 드는 프로젝트였습니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

개발자로서 첫 발표를 마치고

회고 2025. 9. 8. 20:59
728x90
728x90

 

 

첫 발표를 마치고

개발자로서 첫 발표를 무사히(?) 끝마쳤습니다.

 

포스팅을 작성하면서도 가슴이 벌렁벌렁하네요..

 

 

저는 현업에서 혼자 개발하는 환경에 있다 보니,
'내가 잘하고 있는 게 맞을까?' 하는 기술적인 갈증이 늘 있었습니다.
그런 저에게 오픈소스는 코드를 통해 전 세계 누구와도 소통할 수 있는 가장 완벽한 소통의 장이자 탈출구가 되어주었습니다.

최근, 오픈소스 기여 모임 9기에서 제가 오픈소스에 기여하는 과정에 대해 '오픈소스 기여로 레벨업'이라는 주제로 발표하는 기회를 가졌습니다.

 

 

 

발표에서 전하려 했던 것

저는 이 발표를 통해, 단순히 코드를 기여한 경험을 넘어, 다음과 같은 저의 고민과 과정을 전달하고 싶었습니다.

 

1. 기술적 깊이를 더하기

  • 하나의 이슈를 해결하는 것을 넘어, '왜 이 코드가 이렇게 작성되었을까?'라는 질문을 통해 코드의 숨은 의도, 설계 철학등의 새로운 기술적 관점까지 얻어가는 과정을 공유하고 싶었습니다.
  • Prisma의 Breaking Change를 해결하는 과정에서 '타입 퍼포먼스'라는 새로운 관점을 얻고 거대한 오픈소스가 감수하는 기술적 트레이드오프에 대해 깊이 고민해볼 수 있었습니다.

 

2. 주도적으로 가치를 만드는 경험

  • 주어진 이슈를 넘어, 직접 이슈를 찾고 개선하며 주도적으로 가치를 만드는 경험을 나누고자 했습니다.
  • Gemini-CLI의 '첫 실행 시점'처럼 모든 사용자에게 영향을 줄 수 있는 지점을 공략해 실행 과정에서의 문제를 어떻게 발견하고 해결했는지에 대해 공유했습니다.

 

3. AI 활용

  • 이 모든 과정에서 DeepWiki와 같은 AI 도구를 활용해 방대한 오픈소스 코드를 효율적으로 분석하고 학습한 저만의 노하우를 담아보려 했습니다.

 

 

마치며

소심한 성격 탓에 망설임도 많았지만, 발표라는 새로운 시도를 할 수 있도록 기회를 주신 오픈소스 기여 모임의 모든 참여자분들께 진심으로 감사드립니다.

처음 준비하는 발표였기에, 제가 의도했던 이런 고민과 과정이 잘 전달되었을지는 모르겠습니다.

많이 떨리고 부족했지만, 끝까지 귀 기울여 들어주신 모든 분들 덕분에 무사히 마칠 수 있었습니다. 정말 감사합니다.

 

기여 관련 PR과 포스팅은 아래 링크에 있습니다.

 

fix(client): add default generic parameters to PrismaClient constructor by mag123c · Pull Request #27897 · prisma/prisma

Description Adds default values to PrismaClient generic parameters to allow extending without explicit generics. Problem Previously in v6.14.0, extending PrismaClient resulted in TypeScript error c...

github.com

 

 

perf(core): parallelize memory discovery file operations performance gain by mag123c · Pull Request #5751 · google-gemini/gemi

TLDR Parallelizes file I/O operations in memoryDiscovery.ts by converting sequential processing to parallel using Promise.all(), achieving 60%+ performance improvement while maintaining backward co...

github.com

 

Gemini-CLI 실행 속도 개선에 기여하기

서론오픈소스 기여모임 9기가 끝이 났습니다.저는 기여모임 내에서 다양한 오픈소스에 PR을 생성했습니다.nest: 6개의 PRloki: 1개의 PRprisma: 1개의 PR(Merged)gemini-cli: 1개의 PR(Merged)이 중, gemini-cli는 현

mag1c.tistory.com

 

 

Prisma v6.14.0의 성능 개선에 따른 브레이킹 체인지, 이에 기여한 이야기.

서론 들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다. Prisma는

mag1c.tistory.com

 

 

Prisma는 왜 Type-Safe할까?

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장

mag1c.tistory.com

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Claude Code, OOM과 할루시네이션 없이 똑똑하게 사용하는 메모리 최적화 전략

Tech/AI 2025. 9. 8. 20:43
728x90
728x90

 

최근 개발 환경에서 Claude Code와 같은 AI 도구는 선택이 아닌 필수가 되어가고 있습니다. 프로젝트 전체 컨텍스트를 이해하고 코드를 생성해주는 능력은 정말 강력하죠. 하지만 이런 강력함 뒤에는 종종 예기치 못한 문제가 따릅니다. 코딩에 한창 몰입하고 있는데 갑자기 IDE나 터미널이 멈추거나 꺼져버리는 현상(OOM, Out of Memory), 혹은 AI가 대화의 흐름을 잃고 일관성 없는 답변을 내놓는 할루시네이션을 경험해보셨나요?


이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션을 두 달 남짓 몸소 겪고, 직접 레퍼런스들을 뒤져보며 어느정도 개선점을 찾았던 주니어 개발자의 클로드 코드 사용법을 정리하려고 합니다.

 

 

(현 시점에서, 공식 문서에 정확히 기술되어있는 내용들을 바탕으로 유추한 내용도 있습니다.)

 

 

 

이 모든 문제의 중심에는 '메모리', 즉 '토큰 관리'가 있습니다. 오늘은 Claude Code의 토큰 관리 매커니즘을 파헤쳐보고, OOM, 비용 증가, 할루시네이션이라는 세 마리 토끼를 한 번에 잡을 수 있는 메모리 최적화 사용법에 대해 이야기해보려 합니다.

 

 

 

 

 

왜 메모리 최적화가 필요한가요?

본격적인 방법에 앞서, 우리가 왜 Claude의 메모리를 신경 써야 하는지 정리해볼 필요가 있습니다.

 

  • OOM(Out of Memory)으로 인한 프로세스 종료: Claude Code의 대화 세션은 단일 프로세스로 동작합니다. 즉, 대화가 길어질수록 주고받은 모든 토큰이 메모리에 누적되어 시스템의 한계를 초과하면 IDE나 터미널이 강제 종료될 수 있습니다.
  • 의도치 않은 AutoCompact와 할루시네이션: Claude에는 메모리가 부족해지면 자동으로 대화를 요약하는 AutoCompact 기능이 있습니다. 편리해 보이지만, 이 기능이 내가 원치 않는 시점에 작동하면 중요한 컨텍스트가 소실되어 AI가 엉뚱한 답변을 하는 원인이 되기도 합니다.
  • 비용 절약: 결국 API 사용량은 입출력(I/O) 토큰의 양에 따라 결정됩니다. 불필요한 컨텍스트를 계속 유지하는 것은 곧 비용 낭비로 이어지기 때문에, 효율적인 토큰 관리는 비용 절감의 핵심입니다.

 

 

 

 

Claude Code의 메모리 관리 매커니즘 이해하기

아래는, 머메이드를 이용해서 CLAUDE CODE의 워크플로우를 만들어봤습니다.

 

 

 


최적화를 위해서는 Claude가 어떻게 컨텍스트를 기억하는지 알아야 합니다. 핵심은 간단합니다.

  1. 세션은 하나의 프로세스: claude 명령어로 대화형 모드에 진입하면 하나의 세션(프로세스)이 시작됩니다.
  2. 모든 대화는 메모리에: 이 세션 내에서 오고 간 모든 질문과 답변(토큰)은 컨텍스트 유지를 위해 메모리에 계속 쌓입니다.
  3. CLAUDE.md는 항상 로드: 세션을 시작할 때 현재 디렉토리의 CLAUDE.md 파일은 무조건 읽어와 기본 컨텍스트로 사용합니다.

 

결국 대화가 길어질수록 메모리에 쌓이는 토큰이 많아져 위에서 언급한 문제들이 발생하는 구조입니다. 이제 이 구조를 역이용하여 메모리를 통제하는 방법을 알아봅시다.

 

 

 

 

CLAUDE.md는 실행 시 반드시 읽는다. 그러므로

 

Manage Claude's memory - Anthropic

Claude Code can remember your preferences across sessions, like style guidelines and common commands in your workflow. Determine memory type Claude Code offers four memory locations in a hierarchical structure, each serving a different purpose: Memory Type

docs.anthropic.com

 

문서에 따르면, CLI를 실행한 디렉토리를 기준으로 상향/하향으로 CLAUDE.md를 찾아 메모리에 올려 사용합니다.

 

 

 

다시 말해, CLAUDE.md는 claude CLI를 통해 호출할 때 마다 읽는다는 겁니다.

그러므로, CLAUDE.md는 프로젝트 단위의, 사용자 단위의 공통 룰만 정의하고, 나머지는 각 프로젝트 별 마크다운으로 빼서 관리하는 것이 세션 내 메모리와 토큰 비용을 절약하는 효과적인 방법일 것이라고 생각합니다.

 

 

 

 


메모리 최적화를 위한 핵심 명령어 3가지

Claude Code는 메모리를 수동으로 관리할 수 있는 강력한 명령어들을 제공합니다. 이 세 가지만 기억하면 충분합니다.

  • /context: 현재 세션의 '메모리 대시보드'입니다. 이 명령어를 입력하면 현재 컨텍스트가 사용 중인 토큰의 양과 비율을 확인할 수 있습니다. 내비게이션의 지도처럼, 현재 상태를 파악하는 데 필수적입니다.
  • /clear (또는 /reset): 세션을 초기화하는 '하드 리셋' 버튼입니다. 대화 기록과 컨텍스트가 모두 사라지고, CLAUDE.md를 처음부터 다시 로드한 상태가 됩니다. 완전히 새로운 작업을 시작할 때 유용합니다.
  • /compact {지시문}: 세션을 압축하는 '스마트 요약' 기능입니다. 단순히 기록을 지우는 것이 아니라, "지금까지의 논의를 바탕으로 핵심 내용을 요약해줘" 와 같은 지시를 통해 대화의 맥락은 유지하면서 토큰 사용량을 획기적으로 줄여줍니다.

 

 

 

현재 제가 사용중인 방식

이제 위 명령어들을 조합하여 제가 지금 시점에 사용하는 클로드 코드 방식을 소개하려고 합니다.

정답은 없지만, 이 흐름을 따른 후 OOM으로 인한 중단이 사라졌고 토큰 사용량도 눈에 띄게 줄었습니다.


핵심: AutoCompact는 끄고, 수동으로 관리하여 워크플로우의 제어권을 가져온다.

 

자연스러운 중단점 활용: 코딩 작업의 흐름을 Git 워크플로우에 비유해봅시다.

  • Commit 단위로는 /compact: 특정 기능 개발이나 버그 수정 등 작은 작업 단위를 끝냈을 때, /compact를 사용해 "현재까지 작업한 내용을 요약하고 다음 작업을 준비해줘"라고 지시합니다. 이렇게 하면 컨텍스트는 유지하면서 메모리를 확보할 수 있습니다.
  • Branch 단위로는 /clear: 하나의 브랜치에서 다른 브랜치로 넘어가는 것처럼, 완전히 다른 주제의 작업을 시작할 때는 /clear를 사용해 컨텍스트를 완전히 비워줍니다. 이전 작업의 컨텍스트가 새 작업에 영향을 주는 것을 막고 메모리를 최적으로 관리할 수 있습니다.
# (작업 중...) 기능 A 개발 완료 후

# 1. 현재 토큰 사용량 확인
/context

# 2. 컨텍스트 요약으로 메모리 확보
/compact 지금까지 논의한 feature-A의 핵심 로직과 구현 내용을 정리해줘.

# (다른 작업 시작 전...)

# 3. 새로운 feature-B 작업을 위해 세션 초기화
/clear

 

 

이처럼 작업의 흐름에 맞춰 compact와 clear를 전략적으로 사용하면, AI의 AutoCompact에 의해 작업 흐름이 끊기는 안티 패턴을 방지하고 메모리와 토큰 사용량을 모두 최적화할 수 있습니다.

 

 

 

 

어떻게 프롬프팅을 해야 할까?

커서맛피아님의 레퍼런스를 정리한 하조은님의 유튜브 영상 일부 발췌

 

포스팅을 작성하고있는 오늘, 당근에서 개발자로 계신 하조은님의 유튜브 영상을 보다가, 좋은 내용이 있어서 가져왔습니다.

 

항상, 하지말아야 할 것들을 CLAUDE.md에 정의하는 것에 그쳤었는데, 가끔씩 할루시네이션이 발생했던 것을 몸소 체험한 바 있습니다.

영상에서 정리해준 Constraint부분을 특히 프롬프팅 단위로도 잘 정의해야할 것 같습니다.

 

 


마무리

여기까지, 2달 남짓 클로드를 사용하면서 AutoCompact와 OOM 문제 때문에 불필요하게 토큰을 많이 사용하던 어느 주니어 개발자의 이야기었습니다.

 

Claude Code는 강력한 도구지만, 그 성능을 제대로 이끌어내기 위해서는 내부 동작 방식을 이해하고 사용자가 직접 제어하려는 노력이 필요합니다. 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Gemini-CLI 실행 속도 개선에 기여하기

OpenSource 2025. 9. 8. 11:45
728x90
728x90

서론

오픈소스 기여모임 9기가 끝이 났습니다.

저는 기여모임 내에서 다양한 오픈소스에 PR을 생성했습니다.

  • nest: 6개의 PR
  • loki: 1개의 PR
  • prisma: 1개의 PR(Merged)
  • gemini-cli: 1개의 PR(Merged)

이 중, gemini-cli는 현재 AI를 다루는 능력이 거의 필수 스택으로 자리잡았고, 저에게 가장 친숙한 TypeScript기반이라는 점, 마지막으로 내가 구글에 기여할 수 있다니!!! 와 같은 이유로 기여하기로 했는데요 ㅋㅋ..

 

그래서, 이번 포스팅은 gemini-cli의 기여에 대한 포스팅입니다.

 

 

 

Gemini-CLI

 

현 시점 CLI 기반 AI의 양대산맥이라고 한다면, claude code와 gemini-cli가 대표적인데요.

 

여러 차이가 있겠지만, 오픈소스 성격을 띠는지? 의 차이도 있는 것 같아요.

 

 

특히, gemini-cli의 경우 공개적인 로드맵까지 작성되어있어, 관심 있는 이슈를 직접 기여해볼 수 있도록 기여자들의 참여를 적극 장려하고 있는 상황입니다.

 

 

저는 오늘, 9개월 전 첫 오픈소스 기여를 시작하면서 막연하게 꿈꿔왔던 목표인

 

직접 이슈를 발견해서 등록하고, 해결해보기

 

를 달성하고, 더불어 모든 사용자에게 영향을 줄 수 있었던 이슈를 발견하고,

과정에서 AI를 활용하며 기여했던 과정 전반의 경험을 공유드리려고 합니다.

 

 

 

 

어떻게 이슈를 발견할까?

 

오픈소스의 코드는 방대합니다. 주당 몇 만자씩 추가되는 이 방대한 오픈소스에서, 어떻게 이슈를 발견해야할까요? 마냥 하나하나 파일을 분석하기에는, 너무 비효율적입니다.

 

저는 효과적으로 분석하기 위해 우선 UX의 흐름을 생각해보기로 했습니다.

  1. gemini-cli설치하기
  2. 터미널에 "gemini" 명령어 실행하기
  3. 질문하고 응답받고의 반복

방대한 코드를, UX의 흐름으로 재정의하고보니 엄청 단순해졌습니다.

 

저는 이 흐름에서 명령어를 실행한 후 interactive mode(대화형 모드)가 생성되기 전까지의 동작을 확인해보기로 결정했습니다.

즉, 초기화 단계를 중점으로 파헤쳐보기로 한 것이죠.

 

 

 

AI와 함께 분석하기

잘 짜여진 코드 덕분인지 초기화 단계는 금방 찾을 수 있었습니다. 하지만 가시성이 아무리 좋고 좋은 구조로 짜여져 있다고 하더라도, 처음 보는 프로젝트의 모든 상호작용을 파악하면서 정확히 어떤 과정들을 수행하는지 한 눈에 알기는 어려웠습니다.

 

이 때, 시간 낭비를 줄이기 위해 저는 AI를 활용했습니다.

처음에는 할루시네이션 때문에 오픈소스에서 AI를 활용하는 것은 바람직한가? 라는 의문을 품었습니다.

하지만 부정할 수 없는 사실은 AI는 이미, 어쩌면 처음 등장했을 때 부터 제가 알고있는 프로그래밍 지식보다 훨씬 많은 것을 알고 있다는 것을요. 그리고 AI 활용법에 관한 많은 레퍼런스들에서 공통으로 얘기하듯, AI에게 판단을 맡기지 않고 사실 기반으로만 동작하게 한다면 AI는 최고의 동료가 될 것이라는 것을요.

 

저는 그래서, 짜여진 코드라는 사실에 기반하여 초기화 과정 자체를 분석하는 일을 AI에게 맡겼습니다.

 

 

 

 

 

 

DeepWiki: AI 기반 깃허브 저장소 위키

 

DeepWiki는 Devin AI를 만든 Cognition에서 만든 위키 형태의 AI 입니다.

깃허브 저장소를 위키 형태로 변환하여 볼 수 있게 해주는 도구로, 오픈소스의 핵심 아키텍처나 오픈소스 내의 피처들이 어떤 흐름으로 동작하는지 등 오픈소스의 전반적인 것들을 분석해주는 도구입니다.

 

 

 

위 사진은, 애플리케이션의 아키텍처를 DeepWiki가 분석해준 워크플로우이고, 빨간 네모 박스는 실행 시 초기화 과정 전반의 동작들입니다. 이를 기반으로 각 함수들을 하나씩 분석해 본 결과, 단순히 사용자의 세팅을 불러오는 구간들을 배제할 수 있었습니다.

 

작업할 구간을 좁히고 좁히다보니, memoryDiscovery라는 파일에서 퍼포먼스 개선이 가능해보이는 코드를 발견할 수 있었습니다.

 

memoryDiscovery는 초기화 시 GEMINI-CLI가 호출된 디렉토리를 기준으로, 상/하향 디렉토리들을 순차적으로 확인하여 GEMINI.md가 있는 경로를 수집합니다. 수집이 완료되면, 수집된 경로에서 순차적으로 마크다운 파일을 처리하여 메모리에 올려 사용합니다.

 

이 과정을 간단하게 도식화해본다면, 아래와 같은 형태입니다.

 

 

 

 

모든 디렉토리/파일에 대해 순차적인 처리를 수행하기 때문에, 프로젝트가 커지면 문제가 발생하겠다고 생각했고, 이 구간에 대해 작업을 시작했습니다.

 

 

 

Claude Code: 교차 검증

현재 AI는 아시다시피 할루시네이션이 엄청 심합니다.

DeepWiki를 통해 사실 기반의 워크플로우를 추론할 수 있었지만, 사실 확인이 한 번 더 필요하다고 판단했습니다.

 

이를 위해, GEMINI-CLI를 로컬에 클론시킨 뒤, CLAUDE CODE를 통해서 해당 작업 구간을 다시 한 번 재확인했습니다.

 

 

 

 

 

PR 생성

작업 구간이 명확해졌으니, 코드 작업을 해야겠죠?

 

제가 작업한 최종 코드는 다음과 같습니다.

  1. 기존 순차처리를 병렬로 변경
  2. EMFILE 에러 방지를 위한 동시성 제한 추가
  3. Promise.allSettled 사용으로 문제 발생 시에도 안정적인 파일 처리

 

 

PR을 생성하는 과정에서, Promise.allSettled는 gemini가 직접 제안해준 리뷰 내용에 포함되어 있어 추가하였습니다.

(뭔가 제가 만든 요리를 레시피 원작자가 직접 평가하는 기분이네요)

 

 

 

 

 

 

결과

생각보다 금방 머지가되어, GEMINI-CLI의 컨트리뷰터가 될 수 있었습니다.

 

PR 과정에서 얼마나 개선될까 싶어 벤치마크 테스트를 PR의 커밋에 추가했었는데요,

아래 코드에서 알 수 있듯이, 큰 규모의 테스트는 아니지만 약 60% 가량의 퍼포먼스 향상을 이루어낼 수 있었습니다.

/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, beforeEach, afterEach, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { tmpdir } from 'os';
import { loadServerHierarchicalMemory } from './memoryDiscovery.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';

// Helper to create test content
function createTestContent(index: number): string {
  return `# GEMINI Configuration ${index}
## Project Instructions
This is test content for performance benchmarking.
The content should be substantial enough to simulate real-world usage.
### Code Style Guidelines
- Use TypeScript for type safety
- Follow functional programming patterns
- Maintain high test coverage
- Keep functions pure when possible
### Architecture Principles
- Modular design with clear boundaries
- Clean separation of concerns
- Efficient resource usage
- Scalable and maintainable codebase
### Development Guidelines
Lorem ipsum dolor sit amet, consectetur adipiscing elit. 
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
`.repeat(3); // Make content substantial
}

// Sequential implementation for comparison
async function readFilesSequential(
  filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
  const results = [];
  for (const filePath of filePaths) {
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      const processedResult = await processImports(
        content,
        path.dirname(filePath),
        false,
        undefined,
        undefined,
        'flat',
      );
      results.push({ path: filePath, content: processedResult.content });
    } catch {
      results.push({ path: filePath, content: null });
    }
  }
  return results;
}

// Parallel implementation
async function readFilesParallel(
  filePaths: string[],
): Promise<Array<{ path: string; content: string | null }>> {
  const promises = filePaths.map(async (filePath) => {
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      const processedResult = await processImports(
        content,
        path.dirname(filePath),
        false,
        undefined,
        undefined,
        'flat',
      );
      return { path: filePath, content: processedResult.content };
    } catch {
      return { path: filePath, content: null };
    }
  });
  return Promise.all(promises);
}

describe('memoryDiscovery performance', () => {
  let testDir: string;
  let fileService: FileDiscoveryService;

  beforeEach(async () => {
    testDir = path.join(tmpdir(), `memoryDiscovery-perf-${Date.now()}`);
    await fs.mkdir(testDir, { recursive: true });
    fileService = new FileDiscoveryService(testDir);
  });

  afterEach(async () => {
    await fs.rm(testDir, { recursive: true, force: true });
  });

  it('should demonstrate significant performance improvement with parallel processing', async () => {
    // Create test structure
    const numFiles = 20;
    const filePaths: string[] = [];

    for (let i = 0; i < numFiles; i++) {
      const dirPath = path.join(testDir, `project-${i}`);
      await fs.mkdir(dirPath, { recursive: true });

      const filePath = path.join(dirPath, 'GEMINI.md');
      await fs.writeFile(filePath, createTestContent(i));
      filePaths.push(filePath);
    }

    // Measure sequential processing
    const seqStart = performance.now();
    const seqResults = await readFilesSequential(filePaths);
    const seqTime = performance.now() - seqStart;

    // Measure parallel processing
    const parStart = performance.now();
    const parResults = await readFilesParallel(filePaths);
    const parTime = performance.now() - parStart;

    // Verify results are equivalent
    expect(seqResults.length).toBe(parResults.length);
    expect(seqResults.length).toBe(numFiles);

    // Verify parallel is faster
    expect(parTime).toBeLessThan(seqTime);

    // Calculate improvement
    const improvement = ((seqTime - parTime) / seqTime) * 100;
    const speedup = seqTime / parTime;

    // Log results for visibility
    console.log(`\n  Performance Results (${numFiles} files):`);
    console.log(`    Sequential: ${seqTime.toFixed(2)}ms`);
    console.log(`    Parallel:   ${parTime.toFixed(2)}ms`);
    console.log(`    Improvement: ${improvement.toFixed(1)}%`);
    console.log(`    Speedup: ${speedup.toFixed(2)}x\n`);

    // Expect significant improvement
    expect(improvement).toBeGreaterThan(50); // At least 50% improvement
  });

  it('should handle the actual loadServerHierarchicalMemory function efficiently', async () => {
    // Create multiple directories with GEMINI.md files
    const dirs: string[] = [];
    const numDirs = 10;

    for (let i = 0; i < numDirs; i++) {
      const dirPath = path.join(testDir, `workspace-${i}`);
      await fs.mkdir(dirPath, { recursive: true });
      dirs.push(dirPath);

      // Create GEMINI.md file
      const content = createTestContent(i);
      await fs.writeFile(path.join(dirPath, 'GEMINI.md'), content);

      // Create nested structure
      const nestedPath = path.join(dirPath, 'src', 'components');
      await fs.mkdir(nestedPath, { recursive: true });
      await fs.writeFile(path.join(nestedPath, 'GEMINI.md'), content);
    }

    // Measure performance
    const startTime = performance.now();

    const result = await loadServerHierarchicalMemory(
      dirs[0],
      dirs.slice(1),
      false, // debugMode
      fileService,
      [], // extensionContextFilePaths
      'flat', // importFormat
      undefined, // fileFilteringOptions
      200, // maxDirs
    );

    const duration = performance.now() - startTime;

    // Verify results
    expect(result.fileCount).toBeGreaterThan(0);
    expect(result.memoryContent).toBeTruthy();

    // Log performance
    console.log(`\n  Real-world Performance:`);
    console.log(
      `    Processed ${result.fileCount} files in ${duration.toFixed(2)}ms`,
    );
    console.log(
      `    Rate: ${(result.fileCount / (duration / 1000)).toFixed(2)} files/second\n`,
    );

    // Performance should be reasonable
    expect(duration).toBeLessThan(1000); // Should complete within 1 second
  });
});

 

 

 

 

마무리

최근 오픈소스 기여를 위해 DeepWiki를 적극 활용하고 있는데요,

위에서도 잠깐 말씀드렸듯이, 사실 기반(=코드베이스 자체만 분석)으로 AI를 활용한다면 엄청난 퍼포먼스를 보이는 것 같습니다.

오픈소스 기여에는 항상 이슈를 해결하기 위한 분석에서 가장 많은 시간을 잡아먹었었는데요, 작업을 위한 분석 구간을 명확하게 좁혀주는 용도로만 사용했지만 거의 PR 생성 속도가 10배 가까이 단축된 것 같아요.

 

이번 기여에서는, 특히 전 세계 수많은 사용자들의 시간을 매일 조금씩 아껴주었다는 생각에 매우 뿌듯한 경험이었습니다.

모든 gemini-cli의 사용자들이 기존 대비 60%이상 향상된 퍼포먼스를 제 코드를 통해 제공받을 수 있다는 점이 정말 황홀한 것 같아요.

 

읽어주셔서 감사합니다!

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

여러 Claude Max 계정에서 Claude Code 실행 환경 세팅하기

2025. 8. 27. 18:06
728x90
728x90

 

서론

오늘 사내에서 AI 지원 정책이 임시 확정되었습니다. 클로드 코드 맥스, 커서 프로, GPT Plus를 모두 개인 계정으로 지원해주기로 했습니다.

기존에 사용하던 AI 구독료가 굳어서 횡재했다~ 라는 생각을 잠시 했지만, 회사 입장에서는 개인용으로 사용하는 것을 꺼려할 수도 있을 것 같아요. 그래서 개인 계정 구독을 취소하지 않기로 했습니다.


GPT는 GUI로 사용해서 브라우저를 분리하면 되고, 커서도 프로세스별로 다른 계정을 로그인하도록 구성하면 될 것 같은데, 로컬에서 로그인해서 사용하는 CLI 기반의 클로드 코드는 어떻게 다중 접속을 지원하는지 확인이 필요했습니다.

 

 

Reddit의 ClaudeAI 커뮤니티

ClaudeAI 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요

www.reddit.com

 

음.. 일단 안해주는 것 같군요 그럼 직접 구성해봅시다.

 

 

 

 

목표

개인 계정과 회사 계정을 환경적으로 분리하고, 클로드 코드 사용은 기존과 동일하게 CLI 명령어로 동작하도록 구성하고 싶었습니다.

그리고 ccusage를 통해 사용량을 보고 있었기 때문에, ccusage도 환경별로 세팅하고 싶었구요.

 

 

 

 

Docker

독립적인 환경? 바로 컨테이너가 생각났습니다.

# Dockerfile
FROM node:22-slim

# 회사용 Claude Code CLI + ccusage
RUN npm install -g @anthropic-ai/claude-code ccusage

WORKDIR /workspace
CMD ["bash"]

 

도커 파일을 생성했고, 버전에 따라 설정 경로가 달라질 수 있기 때문에 클로드 코드에 필요한 토큰/환경 설정 디렉토리들을 볼륨으로 설정해서 보존했습니다. 또한 서비스 이름/볼륨 이름 모두 고정하여, 폴더가 바뀌어도 같은 볼륨을 사용할 수 있도록 구성했어요.

# docker-compose.yml
name: claude_company

services:
  claude-work:
    build: .
    tty: true
    stdin_open: true
    working_dir: /workspace
    restart: unless-stopped
    command: ["bash","-lc","tail -f /dev/null"]  # 데몬처럼 유지
    environment:
      CLAUDE_CONFIG_DIR: /root/.claude          # 설정/토큰 경로를 하나로 고정
    volumes:
      - ./:/workspace
      - claude_work_state:/root/.claude         # 메인 저장소(토큰/설정)

volumes:
  claude_work_state:
    name: claude_work_state

 

 

실행 스크립트

최초에 구성하고 실행했더니, CLI로 실행한 디렉토리 루트의 경로를 워크스페이스로 인식하지 않고, 컨테이너 내부의 /workspace를 경로로 인식하는 문제가 있었습니다.

호스트의 현재 프로젝트 루트 경로를 컨테이너에서도 똑같은 절대 경로를 사용하게 하기 위해서 다음과 같이 래핑하여 구성하였습니다.

# ~/bin/claude-company
#!/usr/bin/env bash
set -euo pipefail

PROJECT_DIR="$PWD"
UTIL_DIR="$HOME/Desktop/company-docker"   # 실제 경로/대소문자 확인(Desktop)

cd "$UTIL_DIR"

run_compose () {
  # 동일 절대경로 마운트 + 동일 절대경로에서 실행
  if [ -t 0 ]; then
    docker compose run --rm \
      -v "$PROJECT_DIR":"$PROJECT_DIR" \
      -w "$PROJECT_DIR" \
      claude-work "$@"
  else
    docker compose run -T --rm \
      -v "$PROJECT_DIR":"$PROJECT_DIR" \
      -w "$PROJECT_DIR" \
      claude-work "$@"
  fi
}

subcmd="${1-}"
if [ "$subcmd" = "ccusage" ]; then
  shift
  run_compose ccusage "$@"
else
  run_compose claude "$@"
fi

 

mkdir -p ~/bin
chmod +x ~/bin/claude-company
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
which claude-company

 

 

실행

cd ~/Desktop/company-docker
docker compose up -d                       
docker compose run --rm claude-work claude login
## 성공!!
# 1) Claude account with subscription 
# 2) Anthropic Console account

 

도커 컨테이너를 띄워서, 로그인을 시키고 세팅을 완료했습니다.

 

확인해보면, 개인 계정(위)과 회사 계정의 기본 세팅이 다른 것을 볼 수 있죠.

 

 

ccusage도 잘 동작합니다. 위 사진이 개인 계정(내용이 많아서 짤랐습니다.), 아래가 회사 계정이에요.

 

 

 

도커로 띄워야하나? 로컬로 변경

하필 다 만들고 보니 도커는 기본 메모리 사용량이 엄청 높아서, 굳이 도커로 사용해야하나 라는 생각이 들었습니다.
(진작 좀 생각하지 ㅡㅡ)

https://github.com/anthropics/claude-code/issues/261

 

이미 이슈화 되어있는 내용이길래, 개발할 때 환경 변수를 분리하듯이, 토큰/설정만 분리해주면 된다는 것을 뒤늦게 깨달았습니다.

같은 claude를 실행하면서 실행 전에 바라보는 환경 변수 세팅만 다르게 해주면 되는거였어요

 

개인 계정은 그냥 그대로 사용할거니, 회사 계정만 따로 세팅했습니다. 셸 함수를 간단하게 설정했어요.

# nano ~/.zshrc
claude-company()   { CLAUDE_CONFIG_DIR="$HOME/.claude-company"   claude "$@"; }
ccusage-company()  { CLAUDE_CONFIG_DIR="$HOME/.claude-company"   ccusage "$@"; }

# source ~/.zshrc (적용)

 

 

 

이대로 다시 로그인해서 사용하면 되지만, 저는 이미 도커라이징된 클로드가 있었어서, 도커에 있는 볼륨을 카피해서 사용했습니다.

# 회사 디렉터리 생성
mkdir -p ~/.claude-company

# compose 폴더에서(예: ~/Desktop/company-docker)
cd ~/Desktop/company-docker
docker compose run --rm -v "$HOME/.claude-company":/host claude-work \
  bash -lc 'cp -a ~/.claude/. /host/'
  
# 권한도 부여합시다.
chmod 700 ~/.claude-company
find ~/.claude-company -type d -exec chmod 700 {} \;
find ~/.claude-company -type f -exec chmod 600 {} \;

 

 

 

 

CCUSAGE를 위한 추가 설정

ccusage는 찾아보니, API로 계정 조회를 하는 것이 아니라, 로컬에 쌓인 클로드 코드 프로젝트 로그 디렉토리를 스캔해서 집계하는 것 같았습니다. (아니라면 피드백 부탁드립니다.)

 

그래서 회사 계정용 디렉토리를 따로 생성해서, 거기서 보관하면 되지 않을까 생각했어요. 회사 전용 파일을 래핑하고

# 회사 전용 홈 준비(한 번만)
mkdir -p ~/.company-home/.claude ~/.company-home/.config/claude
rsync -a ~/.claude-company/ ~/.company-home/.claude/
rsync -a ~/.claude-company/ ~/.company-home/.config/claude/
find ~/.company-home -type d -exec chmod 700 {} \;
find ~/.company-home -type f -exec chmod 600 {} \;

# ~/bin/ccusage-company  (파일 래퍼)
mkdir -p ~/bin
cat > ~/bin/ccusage-company <<'SH'
#!/usr/bin/env bash
set -euo pipefail
env -u ANTHROPIC_API_KEY -u ANTHROPIC_API_KEY_FILE -u ANTHROPIC_API_KEY_PATH \
    HOME="$HOME/.company-home" \
    XDG_CONFIG_HOME="$HOME/.company-home/.config" \
    CLAUDE_CONFIG_DIR="$HOME/.company-home/.claude" \
    /usr/bin/env ccusage "$@"
SH
chmod +x ~/bin/ccusage-company
echo 'if [[ ":$PATH:" != *":$HOME/bin:"* ]]; then export PATH="$HOME/bin:$PATH"; fi' >> ~/.zshrc && source ~/.zshrc

 

 

마무리

우선 AI를 지원해주는 회사에 무한한 감사를 드립니다.

간단하게 세팅을 마무리해보았는데요, 아마 사내 공통으로 패키징할지는 모르겠어요.

만약 팀 공유에 사용한다면, 공통 스니펫과 래퍼, 체크리스트를 패키징하고 회사 레포에서만 사용할 수 있는 스크립트를 추가하면 회사 내에서만 사용할 수 있는 클로드 코드가 완성될 것 같네요. 쉘을 다루는 낯선 경험을 오랜만에 하게 되어 재밌었습니다.

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Prisma v6.14.0의 성능 개선에 따른 브레이킹 체인지, 이에 기여한 이야기.

OpenSource 2025. 8. 22. 14:44
728x90
728x90

 

 

 

 

서론

오픈소스 메인 브렌치에 박제!

 


들어가기전 기여에 필요해서 Prisma에 대해 간단하게 뜯어본 포스팅이 있으니,

전반적인 내용 이해에 도움이 될 것 같아서 정리해놓았으니 필요 시 한 번 훑어보시길 권장드립니다.

 

Prisma는 왜 Type-Safe할까?

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장

mag1c.tistory.com

 

 

 

 

오픈소스 기여모임 9기의 참여자로 nestjs에 5개, loki, gemini-cli, Prisma에 각 1개씩 7개의 PR을 생성했고, 그 중 2개의 PR이 머지되었습니다. 그 중 Prisma에 컨트리뷰터가 된 내용을 다뤘습니다.

 

제가 처음으로 Prisma 생성한 이번 PR은 6.14.0에 반영된 Prisma의 퍼포먼스 개선에서 발생한 이슈를 해결한 내용으로, 퍼포먼스 개선 PR을 분석해보고, 제 PR이 이전 PR의 어떤 문제를 해결했는지의 순서로 작성되었습니다.

 

 

 

 

문제 원인: 타입 퍼포먼스 개선

6.14.0 릴리즈 노트 안에는, IDE에서 타입 추론에 대한 성능 개선의 내용도 있었습니다.

 

벤치마크 테스트 코드를 보면, 타입 체킹이 약 1300만회에서 1000회 수준으로 엄청난 퍼포먼스 개선이 있었습니다.

하지만 이 변경사항으로 6.12.0에 Preview로 등장한 ESM 호환 Generator를 사용할 때, 기존 처럼 PrismaClient를 사용할 수 없게 되었습니다.

 

 

PR에서, 타입 퍼포먼스 개선을 위해 변경된 주요 코드 PrismaClient 생성자 인터페이스고, 변경 사항은 다음과 같습니다.

// <=6.13.0: ClientOptions와 로그 옵션 제네릭에 기본값이 있었다.
export interface PrismaClientConstructor {
  new <
    ClientOptions extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
    U = LogOptions<ClientOptions>,
    ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
  >(options?: Prisma.Subset<ClientOptions, Prisma.PrismaClientOptions>):
    PrismaClient<ClientOptions, U, ExtArgs>
}

// >=6.14.0: 타입 체크 성능을 높이기 위해 개선
//   1. 기존의 ClientOptions을 분리 (기존의 Options, LogOpts, OmitOpts)
//   2. ⭐️ PrismaClientOptions, LogOptions에 타입 기본값이 제거됨 ⭐️ 
export interface PrismaClientConstructor {
  new <
    Options extends Prisma.PrismaClientOptions,
    LogOpts extends LogOptions<Options>,
    OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
    ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs,
  >(options?: Options): PrismaClient<LogOpts, OmitOpts, ExtArgs>
}

 

 

아래는, 6.14.0에 반영된 타입 퍼포먼스 개선에 대한 PR과, 개선의 이유를 정리해봤습니다.

타입 퍼포먼스 개선의 이유

1. 문제 원인

제가 Prisma의 의도를 정확히 파악할 수는 없겠으나, TypeScript의 덕 타이핑에서 문제를 찾을 수 있었습니다.

type PrismaClientOptions = {
  log?: LogLevel[] | LogDefinition[]
  datasourceUrl?: string
  omit?: {
    [ModelName: string]: {
      [FieldName: string]: boolean
    }
  }
}

// <=6.13.0: 하나의 제네릭
type OldPrismaClient<ClientOptions extends PrismaClientOptions> = {
  user: UserDelegate<ClientOptions>
  post: PostDelegate<ClientOptions>
  // schema.prisma에 정의한 여러 모델들.
}

type UserDelegate<ClientOptions> = {
  findMany: (args?: UserFindManyArgs<ClientOptions>) => Promise<User[]>
  findFirst: (args?: UserFindFirstArgs<ClientOptions>) => Promise<User | null>
  create: (args: UserCreateArgs<ClientOptions>) => Promise<User>
  // Prisma에서 지원하는 수많은 인터페이스들
}

type UserFindManyArgs<ClientOptions> = {
  where?: UserWhereInput
  select?: UserSelect<ClientOptions>
  include?: UserInclude<ClientOptions>
  omit?: ExtractOmit<ClientOptions, 'user'>  // 재귀적으로 Omit하여 Payload 생성
}

type ExtractOmit<ClientOptions, Model> =
ClientOptions extends { omit: infer O }
  ? O extends { [K in Model]: infer Fields }
    ? Fields
    : never
  : never

우리는 PrismaClient 인스턴스를 생성하는 데서 끝이지만, 내부적으로 TypeScript는 거대한 하나의 제네릭 타입을 통해 아래와 같이 타입을 추론합니다. 현재 Nest를 사용하고 있기 때문에, Nest로 예를 들어보겠습니다.

 

일반적으로 Nest에서 PrimsaClient는 다음과 같은 방식으로 사용됩니다.

// 1. PrismaClient를 상속한 DB Connector Service (NestJS 공식문서 예제)
class PrismaService extends PrismaClient {  // 상속 시 타입 체크
  constructor() {
    super({ log: ['query'] })
  }
}

// 2.의존성 주입으로 사용.
class UserService {
  constructor(private prisma: PrismaClient) {}  // 파라미터 타입 체크
}

// 3. 트랜잭션을 위한 유틸리티 함수
async function withTransaction(
  prisma: PrismaClient,  // 파라미터 타입 체크
  callback: (tx: PrismaClient) => Promise<void>
) {
}

// 4. 혹은 일반적인 타입 명시
let myClient: PrismaClient;
myClient = new PrismaClient({ log: ['query'] });

TypeScript는 덕 타이핑을 통해 타입 호환성을 체크합니다.

이 때, 하나의 거대한 제네릭인 ClientOptions가 모든 곳에 전파되어 모든 모델의 메서드가 ClientOptions에 의존하는 상황이 만들어집니다. 덕 타이핑 때문에, 모든 ClientOptions가 있는 생성된 프리즈마 코드들을 돌면서, 구조 전체를 체크해야만 했습니다.

 

간단하게 다시 설명해보겠습니다.

model User {
  id Int @id @default(autoincrement())
  name String
}

위처럼 모델이 user 하나만 있다고 가정해도, 타입 호환성의 체크는 다음과 같이 이루어질 것으로 예상됩니다.

class PrismaService extends PrismaClient {
  constructor() {
    super({ log: ['query'] })
  }
}

// TS는 PrismaService의 인스턴스가 PrismaClient와 호환되는지 체크합니다.
//   프로퍼티, 함수 시그니처, 파라미터 등.

// 프로퍼티 체크
// PrismaService는 PrismaClient의 모든 프로퍼티를 가지는가?
// user: UserDelegate
// $connect(): Promise<void>
// $disconnect(): Promise<void>
// ... 수십 개의 프로퍼티와 메서드

// 메서드 시그니처 체크
// (args?: UserFindManyArgs<{ log: ['query'] }>) => Promise<User[]>
//                           ↑ ClientOptions 전체가 전파

// 메서드 파라미터의 깊은 체크
// UserFindManyArgs의 모든 프로퍼티 체크
// - where?: UserWhereInput
// - select?: UserSelect<{ log: ['query'] }>
// - include?: UserInclude<{ log: ['query'] }>
// - omit?: ExtractOmit<{ log: ['query'] }, 'user'>

// 중첩된 타입들의 재귀적 체크
// select의 각 필드, include의 관계들 등등....
// 이 과정이 모든 모델, 모든 메서드에 대해 반복됩니다.

모델이 커지면 커질수록, 모델의 필드가 많아질수록 비례해서 증가하게 되겠죠. 이는 Prisma의 벤치마크 코드에도 잘 드러나있습니다.

// Prisma의 benchmark 코드 일부
bench('log config applied', () => {
  const client = new PrismaClientConstructor({
    log: [
      { level: 'query', emit: 'event' },
      { level: 'error', emit: 'stdout' },
      { level: 'warn', emit: 'stdout' },
    ],
  })

  /**
   * TypeScript는 덕타이핑을 사용하기 때문에,
   * passClientAround(client) 호출 시점에 client가 
   * PrismaClient 타입과 구조적으로 동일한지 모든 프로퍼티와 메서드를 비교합니다.
   */ 
  const passClientAround = (prisma: PrismaClient) => {
    return prisma
  }

  return passClientAround(client)
}).types([13720983, 'instantiations']) // 1300만건

 

 

2. 개선

위에서 정리한 내용을 바탕으로, 이 문제의 원인은 ClientOptions라는 거대한 하나의 타입만 사용했기 때문입니다.

그래서 Prisma에서는 아래와 같이 타입들을 목적별로 분리한 것 같아요.

  • LogOpts: $on() 메서드에만 영향을 주는 로그 옵션들
  • OmitOpts: 모델 메서드에만 영향을 주는 옵션들

이를 통해 불필요한 타입 전파를 차단하려고 하는 시도였다고 볼 수 있을 것 같습니다. 기대 효과는, 변경된 벤치마크 코드에서도 볼 수 있다시피 엄청난 개선이 되었습니다. 위의 로그 옵션에 대한 벤치마크 코드를 다시 볼까요?

bench('log config applied', () => {
  const client = new PrismaClientConstructor({
    log: [
      { level: 'query', emit: 'event' },
      { level: 'error', emit: 'stdout' },
      { level: 'warn', emit: 'stdout' },
    ],
  })

  const passClientAround = (prisma: PrismaClient) => {
    // @ts-expect-error - using a non-existent event type is a type error
    prisma.$on('foobarbaz', (event) => {
      console.log(event)
    })
    return prisma
  }

  const passToAnyClientAround = (prisma: PrismaClient<any>) => {
    prisma.$on('info', (event) => {
      console.log(event)
    })
    return prisma
  }

  client.$on('query', (event) => {
    console.log(event)
  })

  // @ts-expect-error - info is not a valid event type because we do not pass it in the client options
  client.$on('info', (event) => {
    console.log(event)
  })

  passClientAround(client)
  passToAnyClientAround(client)
}).types([697, 'instantiations']) // 1300만 > 697

타입 세분화를 통해 로그 옵션에 대한 타입 호환성을 체크하다보니, 같은 모델을 정의했다고 하더라도 약 1300만 회에서 700회 정도로 말도 안되는 수치로 감소했던 것을 볼 수 있습니다.

 

 

 

breaking changes 해결하기

다시 제 기여 얘기로 돌아와서, 위 변경사항 때문에 6.14.0으로 업데이트를 했을 때 PrismaClient를 사용하는 거의 모든 코드에서 빨간 줄이 등장(?)하게 되었습니다.

(6.12.0에 도입된 ESM-Generator을 사용할 때 발생하며, 기존의 js Generator은 이상 없는 것으로 확인했습니다.)

 

NestJS 기준의 코드 예시인데요, 어떤 프레임워크든 간에 Prisma를 사용하는 많은 개발자들이 PrismaClient에 제네릭을 명시하지 않고 사용했기 때문에 위 변경 사항은 breaking changes가 될 수 밖에 없습니다. 모든 PrismaClient관련 코드를 수정해야했으니까요. 아래처럼요.

 

PR 내용만 보면 별 거 없는 딱 두 줄의 추가인데요. 저는 기존 퍼포먼스 향상을 유지하면서도 많은 개발자들이 코드를 수정하지 않고도 기존 코드 그대로 사용이 가능하도록, PrismaClient에 디폴트 타입을 추가해줬습니다.

// Options, LogOpts에 디폴트 옵션 추가.
export interface PrismaClientConstructor {
  ${indent(this.jsDoc, TAB_SIZE)}
  new <
    Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions,
    LogOpts extends LogOptions<Options> = LogOptions<Options>,
    OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends { omit: infer U } ? U : Prisma.PrismaClientOptions['omit'],
    ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs
  >(options?: Prisma.Subset<Options, Prisma.PrismaClientOptions> ): PrismaClient<LogOpts, OmitOpts, ExtArgs>

 

 

이렇게 해서, 바로 PR이 merge가 되었고, 제 PR만 단독으로 머지된 탓에, 아마 Latest Commit에 제 프로필 사진이 올라가지 않았나 싶네요.

 

 

 

정리

Prisma를 거의 처음 사용해보면서, 오픈소스 기여를 위해 관련 코드를 깊이 파헤쳐보고 궁금증이 생겨 Prisma가 왜 Type-Safe한 ORM인지까지 돌아봤습니다. 물론 프리즈마 엔진 코드가 생소한 Rust이고, Prisma에 익숙하지 않아 분석이 다소 완벽하지 않았네요.

 

단 한 줄, 두 줄의 코드 변경으로 대다수의 개발자들에게 breaking changes 없이 확장된 기능, 더 좋은 퍼포먼스를 제공할 수 있는 기여를 했다는 생각에 현재까지 기여 중에 코드 길이 대비 가장 뿌듯했던 기여 순간이었던 것 같습니다. 더불어 TypeScript와도 조금 더 친해지는 계기가 되었던 것 같아요.

 

오픈소스 기여는 이렇게 단 한줄의 변경으로 수 억명의 사람들에게 임팩트를 줄 수 있고, 더불어 사용하고 있는 기술에 대한 깊은 이해, 기술의 기반이 되는 더 깊이 있는 지식까지도 습득할 수 있는 좋은 기회인 것 같습니다. 앞으로도 여기저기 사용하는 기술들에 대해 관심 있게 둘러 볼 예정입니다.

 

 

오픈 소스 기여에 어려움을 겪고 계신 분들이 있다면, 인제님이 운영하시는 오픈소스 기여 모임에 참여해보시는 것은 어떨까요?

다양한 분야에서 여러 기여를 하신 운영진분들과 참여자분들과 소통하면서, 이슈 선정부터 PR 기여까지 많은 도움을 얻을 수 있습니다!

 

 

 

 

 

 

참조

https://www.prisma.io/

https://www.typescriptlang.org/docs/handbook/2/generics.html

https://github.com/prisma/prisma/releases/tag/6.14.0

https://github.com/prisma/prisma/pull/27777

https://stackoverflow.com/questions/70545982/why-am-i-getting-type-instantiation-is-excessively-deep-and-possibly-infinite?utm_source=chatgpt.com

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Prisma는 왜 Type-Safe할까?

OpenSource 2025. 8. 21. 23:52
728x90
728x90

 

TypeORM을 쓰던 개발자분들은 거의 대부분 Prisma 쓰세요!!! 라고 하더군요.

제가 눈팅하는 Node, Nest관련 커뮤니티들에서도 TypeORM은 기피하고 Prisma를 권장하는 분위기인 것을 종종 느꼈습니다. (굉장히 주관적임) 이 주장의 근간에는 Type-Safe한 ORM인것이 메인일 것 같습니다. (물론 다른 장점들도 많을 것 같은데 차차 파헤쳐보죠)

 

이번에 오픈소스 기여를 통해 처음으로 Prisma 코드를 약간 파보았습니다.

TypeORM만 쓰고 거의 겉핥기식으로 사용했었는데, 이번 기회에 제대로 Prisma에 입문해보려고 합니다.

 

 

 

 

Prisma

Prisma는 스키마 기반 코드 생성형 ORM입니다. schema.prisma를 바탕으로 런타임 이전에 Prisma Client를 생성해야합니다. (prisma generate)

 

https://www.prisma.io/typescript

 

그래서일까요? 많은 레퍼런스들에서도 Type-Safe하다고 언급하고, 공식문서에서도 fully type-safe, zero-cost type-safe하다고 명시되어있습니다. 이쪽 진영(?)의 ORM이 익숙하지 않으신 분들을 위해 예시를 준비했습니다.

 

비교 대상은 이름부터 엄청나게 type-safe할 것 같은 TypeORM입니다.

 

 

TypeORM vs Prisma

class UserEntity {
    @PrimaryGeneratedColumn('increment')
    id!: number;
    @Column()
    name!: string;
}

async getName(id: number) {
    const user = await this.userRepostiory.findOne({
    	select: ['name'],
        where: { id }
    });
    return user;
}

 

코드를 통해 getName()의 리턴 타입이 Pick<UserEntity, 'name'>일 것으로 예상됩니다.

하지만 TypeORM은 Entity 자체를 타입으로 추론합니다. 엉뚱하죠? 실제 동작과 타입 추론이 어긋납니다.

getName()에서 user를 할당한 후, user.id에 접근도 잘 됩니다. IDE도 올바르게 추적해주고, 경고나 에러도 발생시키지 않죠.

 

물론, getName을 호출하는 함수의 리턴타입을 강제하는 방법도 있습니다만, 우리는 그걸 원치 않습니다.

물론, TypeORM을 사용하면서 전역으로 타입을 래핑하는 인터페이스를 만들 수도 있습니다만, 우리는 그걸 원치 않습니다.

오픈소스를 사용하는 이유는 다양하지만, 편리하고 다양한 기능을 제공받아 사용하는 이유도 있으니까요. 결국 개발자가 직접 래핑해야 하는 번거로움이 생기죠 (귀찮아요 ㅡㅡ (?))

 

 

 

 

Prisma는 어떨까요?

model User {
  id   Int    @id @default(autoincrement())
  name String
}

class PrismaService extends PrismaClient {
  async getUserName(id: number) {
    return await this.user.findFirst({
      where: { id },
      select: {
        name: true,
      },
    });
  }
}

 

 

Prisma에서 같은 쿼리를 실행시켜보면, 결과는 정확하게 select된 프로퍼티들만 타입으로 추론합니다.

 

 

 

TypeORM의 타입 추론

간략한 이유는 다음과 같습니다.

 

TypeORM은, 테이블 모델 설계 문서가 아닌 실제 클래스 형태로 엔터티를 작성합니다. 그리고 Repository Interface 사용을 위해 TypeORM의 Repository 클래스의 제네릭으로 엔터티 타입을 명시하여 사용하죠. Repository의 각 인터페이스들은 이 Entity 제네릭 타입을 받아서 타입 추론을 하도록 구성되어있습니다. 아래처럼요.

const userRepository = new Repository<UserEntity>();

class Repository<Entity extends ObjectLiteral> {
	findOne(options?: FindOneOptions<Entity>): Promise<Entity | undefined>;
}

 

ORM에서 FindOptions의 select의 키인 keyof Entity로 타입 추론을 해주지 않는 이상, 위에서 말한 것 처럼 개발자가 직접 타입 좁히기를 통해 type-safe하게 만들어서 사용해야합니다.

 

 

Prisma의 타입 추론

반면에 Prisma는 다르죠. Prisma는 스키마 기반 코드 생성 ORM입니다.

런타임 전에 schema.prisma를 해석해서(prisma generate) @prisma/client를 만들어 둡니다. 그래서 쿼리 작성 시점부터 타입이 이미 결정되어 있고, select/include에 따라 반환 타입이 정교하게 내로잉됩니다.

 

코드를 보면서 간략하게 타입을 만들어내는 파이프라인을 요약해봤습니다.

schema.prisma > Rust/WASM 파서 > DMMF JSON > DMMF Helper 클래스 > TSClient 코드 생성 > 파일 출력(@prisma/client로)

 

각 과정들을 조금 더 자세히 보도록 하죠.

 

1. prisma generate

npx prisma generate와 같은 명령어를 사용해보셨을거에요. Prisma의 타입 생성은 이 명령어로 시작됩니다.

이 명령은 CLI 내부의 Generate.ts에서 실행 흐름이 잡히고, 이후 각 generator을 초기화하고 실행시킵니다.

// packages/cli/src/Generate.ts
export class Generate implements Command {
  async execute() {
    // generator 등록 및 실행 흐름 담당
  }
}

 

이 때, getGenerators 함수가 팩토리 역할을 수행하고, schema.prisma의 generator client { ... } 설정을 읽고 @prisma/client 생성기를 찾아 초기화합니다.

 

 

2. Schema 파싱 및 가공

schema.prisma를 중간 포맷인 DMMF(Data Model Meta Format)으로 파싱합니다.

이 과정은 Rust로 구현된 프리즈마 엔진의 파서를 WASM(WebAssembly)로 컴파일해서 NodeJS에서 실행합니다.

// packages/client/src/getDmmf.ts
const dmmf = await wasm.getDMMF({ datamodel: schema })

 

직접 wasm이 구현되어 있는 Rust코드를 당장에는 이해할 수 없어, 축약하자면 getDMMF를 통해 스키마가 해석되고, 결과는 JSON 형태의 DMMF로 변환됩니다. 이 JSON형태 위에 모델, 필드, 연관 관계등을 쉽게 탐색할 수 있도록 DMMF 클래스로 래핑되고, 모델/타입/연산을 빠르게 조회할 수 있는 Map이 만들어집니다.

 

 

3. TypeScript 코드 생성(PrismaClient)

이 과정 후에, PrismaClientTsGenerator에 의해 Prisma Client 코드를 생성하는데요.

export class TSClient {
  protected readonly dmmf: DMMFHelper
  protected readonly genericsInfo: GenericArgsInfo

  constructor(protected readonly options: TSClientOptions) {
    this.dmmf = new DMMFHelper(options.dmmf)
    this.genericsInfo = new GenericArgsInfo(this.dmmf)
  }

  generateClientFiles(): FileMap {
    const context = new GenerateContext({
      dmmf: this.dmmf,
      genericArgsInfo: this.genericsInfo,
      runtimeImport: `${this.options.runtimeBase}/${this.options.runtimeName}`,
      outputFileName: generatedFileNameMapper(this.options.generatedFileExtension),
      importFileName: importFileNameMapper(this.options.importFileExtension),
      generator: this.options.generator,
    })

    const modelNames = Object.values(context.dmmf.typeAndModelMap)
      .filter((model) => context.dmmf.outputTypeMap.model[model.name])
      .map((model) => model.name)

    const modelsFileMap: FileMap = modelNames.reduce((acc, modelName) => {
      acc[context.outputFileName(modelName)] = createModelFile(context, modelName)
      return acc
    }, {})

    return {
      [context.outputFileName('client')]: createClientFile(context, this.options),
      [context.outputFileName('enums')]: createEnumsFile(context),
      [context.outputFileName('commonInputTypes')]: createCommonInputTypeFiles(context),
      [context.outputFileName('models')]: createModelsFile(context, modelNames),
      models: modelsFileMap,
      internal: {
        [context.outputFileName('prismaNamespace')]: createPrismaNamespaceFile(context, this.options),
        [context.outputFileName('class')]: createClassFile(context, this.options),
      },
    }
  }
}

 

위에서 열심히 만든 JSON 래핑 클래스인 DMMFHelper을 토대로 PrismaClient를 생성합니다. 모델별 CRUD 메서드와 쿼리 옵션, 타입 시그니처들이 생성되게 되죠. 생성되는 대표 파일 구성은 대략 다음과 같습니다.

  • client: class PrismaClient { user: UserDelegate; ... } 및 각 모델 delegate 메서드 시그니처
  • models/*, models.ts: 모델 단위 타입 조각들
  • enums.ts: 스키마 enum → TS enum/type
  • commonInputTypes.ts: WhereInput, OrderBy, ScalarWhereWithAggregates 같은 공용 인풋 타입
  • internal/prismaNamespace.ts: Prisma.UserGetPayload<S>, Prisma.UserSelect 등 타입 네임스페이스
  • 런타임 번들 참조: 환경별(node, edge, react-native, wasm) 런타임 import 경로 세팅

 

 

그래서 왜 Prisma는 type-safe한데?

간략히 살펴본 generate 파이프라인의 결과물이 미리 계산된 타입 정의인데요.

이 타입 안에는 Model, Output, Payload라는 개념이 녹아있습니다.

  • Model: 스키마의 row에 대한 기본 스칼라가 typescript 인터페이스로 정의된 타입.
  • Output: 집계/그룹핑 처럼 형태가 확정적인 연산 결과는 AggregateUser, UserGroupByOutputType같은 명시적 Output 타입으로 노출돼요. row 관점의 원형 출력은 별도 파일/타입명으로 고정되어있지 않고 아래 Payload가 그 역할을 수행합니다.
  • Payload: 기본 정의된 Payload를 기반으로 제네릭 타입이 들어오면, 조건부/매핑 유틸리티 타입이 이를 해석해서 Payload를 즉석해서 추론합니다.
export type $UserPayload<ExtArgs = $Extensions.DefaultArgs> = {
  name: 'User';
  objects: {};                      // 관계(객체) 필드
  scalars: { id: number; name: string };  // 스칼라 필드
  composites: {};
}

type UserGetPayload<S extends boolean | null | undefined | UserDefaultArgs> =
  $Result.GetResult<Prisma.$UserPayload, S>;

 

 

다시 돌아와서, 위의 쿼리를 다시 살펴볼까요?

const user = await this.user.findFirst({
  where: { id },
  select: {
    name: true,
  },
});

>>

findFirst<T extends UserFindFirstArgs>(
  args?: SelectSubset<T, UserFindFirstArgs<ExtArgs>>,
): Prisma__UserClient<
  $Result.GetResult<
    Prisma.$UserPayload<ExtArgs>,  // 모델 전체의 스칼라 정의
    T,                             // args의 타입 ( { select: { name: true }}
    'findFirst',
    GlobalOmitOptions
  > | null,
  null,
  ExtArgs,
  GlobalOmitOptions
>;

 

여기서 일어나는 타입 계산을 단계별로 풀어보면

  • T 캡처: args 타입인 ({ select: { name: ture } })가 그대로 T로 캡처됩니다.
  • 유효성 검사: SelectSubset에 의해 허용되는 옵션만 통과시키도록 컴파일 타임에 필터링돼요.
  • 반환 타입 계산: 내부의 $Result.GetResult가 $UserPayload에서 T.select를 읽어 name만 뽑아 { name: string } 객체를 만듭니다. findFirst 특성상 결과가 없을 수 있기 때문에 | null이 붙게 돼요.

결과적으로, user.id에 접근하면 IDE에서 바로 타입 에러가 발생하는 메시지가 보이게 되는거죠.

 

 

 

 

 

정리

사실 한 번에 뜯어보고 이해를 온전히 할 수 없었어요.

제가 많은 오픈소스를 보지는 않았지만, TS기반의 오픈소스 중 가장 해석하기 힘들었습니다. 하지만 이런 파이프라인을 통해 왜 type-safe하다는 건지 어느정도 실마리를 잡은 것 같아요. 다음엔, 이렇게 Prisma를 뜯어보게 된 원인이 되었던 Prisma 6.14.0 업데이트의 브레이킹 체인지를 해결하기 위한 기여를 했던 경험을 공유해보겠습니다.

 

모자란 글이지만 읽어주셔서 감사합니다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

티스토리 스킨 무료 배포합니다!! 제발 사용해주세요!!

2025. 8. 20. 22:09
728x90
728x90

 

기술 블로그를 3년 가까이 운영하면서, 언젠가는 티스토리 커스텀 스킨을 만들어야지 생각했습니다. 하지만 저는 프론트, 특히 CSS를 잘 다루지 못해서 언젠간 만들어야지 생각만 했던 것 같아요.

 

드디어!!! 커스텀 스킨을 제작했습니다. 물론 클로드 코드를 사용해서 바이브 코딩으로 만들었습니다. (CSS가 너무 어렵군요..)

티스토리 커스텀 스킨을 무료로 배포합니다!!! 제발 사용해주세요!!!(??)

 

제 블로그 적용되어있는 상태이니 둘러보시고 마음에 드신다면 아래 레포의 가이드를 따라 사용해주시면 되겠습니다. 비개발자분들도 접속하셔서 코드를 ZIP으로 다운로드만 받아서 티스토리에 등록만 하면 사용이 가능합니다. 사용에 어려움이 있으신 분은 댓글 남겨주시면 답변 드리겠습니다.

 

GitHub - mag123c/tistory-mag1c-skin

Contribute to mag123c/tistory-mag1c-skin development by creating an account on GitHub.

github.com

 

저도 실사용 유저로서 사용성에 문제가 있는 것들을 최우선으로 작업하면서 더 나은 방향으로 개선해보겠습니다.

 

 

 

 

 

여담이지만, 스킨 테스트를 한다고 빈 계정을 만들어서 글을 막 올리면서 테스트하고있었는데, 30명이나 방문하더라구요.. 뭐지?

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Rate Limit - 3] Fixed Window Counter, Sliding Window Logging, Sliding Window Counter 알고리즘

카테고리 없음 2025. 8. 11. 18:00
728x90
728x90

[목차]

1. Rate Limit이란? 설계 시 주의사항

2. Token Bucket, Leaky Bucket 알고리즘

3. Fixed Window, Sliding Window Logging, Sliding Window Counter 알고리즘

 

 

지난 포스팅에선, Bucket 방식의 Token Bucket, Leaky Bucket 알고리즘을 살펴보고, 직접 구현과 테스트를 진행했다.

이번 글에선 Window 방식의 알고리즘인 Fixed Window Counter, Sliding Window Logging, Sliding Window Counter을 다룬다.

 

모든 예제 코드는 아래 Github에서 확인할 수 있다.

 

GitHub - mag123c/rate-limiter

Contribute to mag123c/rate-limiter development by creating an account on GitHub.

github.com

 

 

 

 

Fixed Window Counter

Fixed Window는 일정한 간격의 윈도우로 구간을 나누고, 각 구간마다 카운터를 붙인다. 예를 들어, 1초마다 윈도우를 나누고 임계치를 3으로 설정하면, 요청이 들어올 때마다 카운터를 증가시키고, 임계치를 초과하면 요청을 거부한다.

 

 

 

장점

  • 구현이 단순하다. (윈도우 시작 시간과 카운터만 관리)
  • 메모리 사용이 매우 효율적이다.
  • “1초에 3번”, “1분에 100번” 같은 정책을 직관적으로 표현 가능하다.

단점

  • 경계 구간에서 버스트가 발생할 수 있다.
    예: 1분에 100회 제한인데 00:59에 100회, 01:00에 100회 요청이 오면 2초 동안 200회 처리가 필요하다.
  • 짧은 시간 내 몰리는 트래픽을 균등하게 제어할 수 없다.
    예: 같은 윈도우 안에서 동시에 100회 요청이 들어오면 모두 허용된다.

 

만들어보기

type FixedWindowClearConfig = {
  callCount: number;
  maxCount: number;
};

export interface FixedWindowConfig {
  threshold: number; // 윈도우 내 허용되는 최대 요청 수
  windowSizeMs: number; // 윈도우 크기 (밀리초)
  clearConfig?: FixedWindowClearConfig;
}

 

위의 설명처럼 구현이 단순하다. 윈도우 설정에는 임계치와 윈도우 크기를 지정한다.

clearConfig는 카운팅을 통해 LRU 방식으로 데이터를 삭제하려고 만들어봤다. (Redis의 TTL처럼 구현할 계획이다.)

 

type FixedWindow = {
  counter: number;
  windowStart: number;
};

 

윈도우는, 특정 요청 주체의 counter와 마지막 요청 시간을 기록한다.

 

export class FixedWindowRateLimiter implements RateLimiter {
  private windows: Map<string, FixedWindow> = new Map();

  constructor(private config: FixedWindowConfig) {}

  tryConsume(key: string): void {
    if (
      this.config.clearConfig?.callCount &&
      this.config.clearConfig?.maxCount &&
      this.config.clearConfig.callCount >= this.config.clearConfig.maxCount
    ) {
      this.cleanupExpiredWindows();
      this.config.clearConfig.callCount++;
    }

    if (!this.canConsumeRequest(key)) {
      throw new Error(`Rate Limit Exceeded for key: ${key}`);
    }
    this.increaseCounter(key);
  }

  private canConsumeRequest(key: string): boolean {
    let window = this.windows.get(key);
    if (!window) {
      window = this.createWindow(key);
    }
    this.initializeCounter(window);
    return window.counter < this.config.threshold;
  }

  private increaseCounter(key: string) {
    const window = this.windows.get(key);
    if (!window) {
      throw new Error(`Window not found for key: ${key}`);
    }
    window.counter++;
  }

  private initializeCounter(window: FixedWindow) {
    const now = Date.now();
    if (now - window.windowStart >= this.config.windowSizeMs) {
      window.counter = 0;
      window.windowStart = now;
    }
  }

  private createWindow(key: string): FixedWindow {
    const window: FixedWindow = {
      counter: 0,
      windowStart: Date.now(),
    };
    this.windows.set(key, window);
    return window;
  }

  // TTL 기반 삭제
  private cleanupExpiredWindows() {
    const now = Date.now();
    const ttl = this.config.windowSizeMs * 10;

    for (const [key, window] of this.windows.entries()) {
      if (now - window.windowStart >= ttl) {
        this.windows.delete(key);
      }
    }
  }
}

 

구현 또한 매우 단순하다.

  • 요청이 들어올 때 마다 카운터를 초기화해야한다면 초기화한다. 그렇지 않다면 유지한다
  • 카운터 설정에 설정된 임계치를 카운터가 초과했다면 429를 반환한다.

Redis TTL을 비슷하게 구현해보고자 cleanupExpiredWindows()를 clearConfig와 엮어 구현해본 것이 커스터마이징의 전부였고, 구현에 별다른 어려움은 없다.

 

 

통합 테스트

import request from "supertest";
import { createApp } from "../../app";
import { createFixedWindowMiddleware } from "../middleware";

describe("Fixed Window Rate Limiter Integration", () => {
  afterEach(() => {
    jest.useRealTimers();
  });

  it("윈도우 내의 임계치에 도달하면 429 에러가 발생한다", async () => {
    const threshold = 10;
    const windowSizeMs = 5000;
    const rateLimiter = createFixedWindowMiddleware({
      threshold,
      windowSizeMs,
    });

    const app = createApp({
      middlewares: [rateLimiter],
    });

    for (let i = 0; i < threshold; i++) {
      const response = await request(app).get("/");
      expect(response.status).toBe(200);
    }

    const rejectedResponse = await request(app).get("/");
    expect(rejectedResponse.status).toBe(429);
    expect(rejectedResponse.body.error).toBe("Too Many Requests");
  });

  it("다른 IP 주소는 독립적인 rate limit을 가진다", async () => {
    const rateLimiter = createFixedWindowMiddleware({
      threshold: 2,
      windowSizeMs: 5000,
    });

    const app = createApp({ middlewares: [rateLimiter] });

    await request(app).get("/").set("X-Forwarded-For", "1.1.1.1");
    await request(app).get("/").set("X-Forwarded-For", "1.1.1.1");

    await request(app).get("/").set("X-Forwarded-For", "2.2.2.2");
    await request(app).get("/").set("X-Forwarded-For", "2.2.2.2");

    const response1 = await request(app)
      .get("/")
      .set("X-Forwarded-For", "1.1.1.1");
    const response2 = await request(app)
      .get("/")
      .set("X-Forwarded-For", "2.2.2.2");

    expect(response1.status).toBe(429);
    expect(response2.status).toBe(429);
  });

  it("동시 요청 처리 시 정확한 카운팅", async () => {
    const threshold = 50;
    const rateLimiter = createFixedWindowMiddleware({
      threshold,
      windowSizeMs: 5000,
    });

    const app = createApp({ middlewares: [rateLimiter] });

    // 50개 동시 요청
    const promises = Array(threshold)
      .fill(null)
      .map(() => request(app).get("/"));

    const responses = await Promise.all(promises);
    const successCount = responses.filter((r) => r.status === 200).length;
    const failCount = responses.filter((r) => r.status === 429).length;

    // 정확히 threshold만큼만 성공
    expect(successCount).toBe(threshold);
    expect(failCount).toBe(0);

    // 추가 요청은 실패
    const extraResponse = await request(app).get("/");
    expect(extraResponse.status).toBe(429);
  });

  it("커스텀 키 생성기 사용 시 올바르게 동작", async () => {
    const rateLimiter = createFixedWindowMiddleware(
      {
        threshold: 2,
        windowSizeMs: 5000,
      },
      {
        keyGenerator: (req) =>
          req.headers["api-key"]?.toString() || "anonymous",
      }
    );

    const app = createApp({ middlewares: [rateLimiter] });

    // API 키 "key1"로 2번 요청
    await request(app).get("/").set("api-key", "key1");
    await request(app).get("/").set("api-key", "key1");

    // API 키 "key2"로 2번 요청
    await request(app).get("/").set("api-key", "key2");
    await request(app).get("/").set("api-key", "key2");

    // 각 키별로 임계치 확인
    const response1 = await request(app).get("/").set("api-key", "key1");
    const response2 = await request(app).get("/").set("api-key", "key2");

    expect(response1.status).toBe(429);
    expect(response2.status).toBe(429);

    // anonymous 키는 별도 카운트
    const anonymousResponse = await request(app).get("/");
    expect(anonymousResponse.status).toBe(200);
  });

  it("윈도우 경계에서 burst traffic 발생 가능 (Fixed Window의 한계)", async () => {
    const threshold = 10;
    const windowSizeMs = 1000; // 1초
    const rateLimiter = createFixedWindowMiddleware({
      threshold,
      windowSizeMs,
    });

    const app = createApp({ middlewares: [rateLimiter] });

    // 실제 시간 기반 테스트
    const startTime = Date.now();

    // 첫 번째 윈도우에서 threshold만큼 요청
    for (let i = 0; i < threshold; i++) {
      const response = await request(app).get("/");
      expect(response.status).toBe(200);
    }

    // 윈도우가 끝날 때까지 대기
    const elapsedTime = Date.now() - startTime;
    const remainingTime = windowSizeMs - elapsedTime + 100; // 여유 시간 추가

    if (remainingTime > 0) {
      await new Promise((resolve) => setTimeout(resolve, remainingTime));
    }

    // 새 윈도우에서 다시 threshold만큼 요청 가능 (threshold * 2 BURST)
    for (let i = 0; i < threshold; i++) {
      const response = await request(app).get("/");
      expect(response.status).toBe(200);
    }
  });

  it("skip 옵션 사용 시 특정 요청은 rate limit 제외", async () => {
    const rateLimiter = createFixedWindowMiddleware(
      {
        threshold: 2,
        windowSizeMs: 5000,
      },
      {
        skip: (req) => req.path === "/health",
      }
    );

    const app = createApp({
      middlewares: [rateLimiter],
      setupRoutes: (app) => {
        app.get("/health", (_req, res) => res.json({ status: "ok" }));
        app.get("/api/users", (_req, res) => res.json({ users: [] }));
      },
    });

    // 일반 요청은 rate limit 적용
    await request(app).get("/api/users");
    await request(app).get("/api/users");
    const limitedResponse = await request(app).get("/api/users");
    expect(limitedResponse.status).toBe(429);

    // health check는 rate limit 제외
    for (let i = 0; i < 10; i++) {
      const response = await request(app).get("/health");
      expect(response.status).toBe(200);
    }
  });

  it("onLimitReached 콜백이 호출된다", async () => {
    let callbackCalled = false;
    let limitedPath = "";

    const rateLimiter = createFixedWindowMiddleware(
      {
        threshold: 1,
        windowSizeMs: 5000,
      },
      {
        onLimitReached: (req, res) => {
          callbackCalled = true;
          limitedPath = req.path;
          res.status(429).json({ error: "Custom limit message" });
        },
      }
    );

    const app = createApp({ middlewares: [rateLimiter] });

    await request(app).get("/test");
    const response = await request(app).get("/test");

    expect(callbackCalled).toBe(true);
    expect(limitedPath).toBe("/test");
    expect(response.status).toBe(429);
    expect(response.body.error).toBe("Custom limit message");
  });

  it("다양한 HTTP 메서드에 대해 동일하게 작동", async () => {
    const rateLimiter = createFixedWindowMiddleware({
      threshold: 5,
      windowSizeMs: 5000,
    });

    const app = createApp({ middlewares: [rateLimiter] });

    // 다양한 메서드로 요청
    await request(app).get("/");
    await request(app).post("/");
    await request(app).put("/");
    await request(app).delete("/");
    await request(app).patch("/");

    // 임계치 도달
    const response = await request(app).get("/");
    expect(response.status).toBe(429);
  });
});

 

기본적인 기능들과 더불어, 단점들에 대해서도 테스트가 통과되는 모습이다.

 

 

 

Sliding Window Logging

Fixed Window Counter의 가장 큰 한계는 윈도우 경계 부근 버스트를 막을 방법이 없다는 점이다. Sliding Window Logging은 이를 해결한다. 아래 설명들을 보면 알겠지만, 스코어를 저장하여 순서대로 로깅하고 조회할 수 있는 Redis ZSET을 이용하여 구현이 쉽게 가능하다.

 

 

동작 원리

  1. 요청 시 타임스탬프를 로그에 기록한다. (1, 2)
  2. 만료된 타임스탬프는 로그에서 제거한다. (3)
  3. 로그 크기가 임계치 이하이면 요청 허용, 초과하면 거부. (3, 4)

이 방식은 정적인 경계가 없으므로 언제나 임계치 이하로만 처리할 수 있다.
다만, 거부된 요청의 타임스탬프도 기록하기 때문에 Fixed Window 대비 메모리를 더 사용한다.

 

장점

  • 버스트 방지에 강하다.
  • 처리율을 안정적으로 유지할 수 있다.

단점

  • 로그 크기만큼 메모리 사용.
  • 요청 수가 많으면 GC/CPU 부하가 커질 수 있음(배열 filter 기반 구현 시).

 

 

만들어보기

export interface SlidingWindowConfig {
  threshold: number; // 윈도우 내 허용되는 최대 요청 수
  windowSizeMs: number; // 윈도우 크기 (밀리초)
}

 

윈도우 방식의 알고리즘들은 기본 설정은 비슷하다. 이번에도 역시 임계치와 윈도우 사이즈만 기본 설정에 넣었다.

 

export class SlidingWindowLoggingRateLimiter implements RateLimiter {
  private timestamps: Map<string, number[]> = new Map();

  constructor(private config: SlidingWindowLoggingConfig) {}

  tryConsume(key: string): void {
    const now = Date.now();
    if (!this.canConsumeRequest(key, now)) {
      throw new Error(`Rate limit exceeded`);
    }
    this.addTimestamp(key, now);
  }

  private canConsumeRequest(key: string, now: number): boolean {
    let timestamps = this.timestamps.get(key);
    if (!timestamps) {
      timestamps = [];
      this.timestamps.set(key, timestamps);
    }

    const windowStart = now - this.config.windowSizeMs;

    // 윈도우 내의 요청만 필터링
    const validTimestamps = timestamps.filter(
      (timestamp) => timestamp >= windowStart
    );

    // 메모리 효율을 위해 오래된 타임스탬프 제거
    if (validTimestamps.length !== timestamps.length) {
      this.timestamps.set(key, validTimestamps);
    }

    return validTimestamps.length < this.config.threshold;
  }

  private addTimestamp(key: string, now: number): void {
    const timestamps = this.timestamps.get(key);
    if (!timestamps) {
      throw new Error(`Timestamps not found for key: ${key}`);
    }
    timestamps.push(now);
  }
}

 

순수 JS로 구현했기에 filter을 사용했지만, 매커니즘은 똑같다.

요청 시점에 타임스탬프 로그들을 적재하고 제거한다. 그리고 요청이 수행될 수 있는지를 검증하여 작업 요청을 통과시키거나 드랍시킨다.

 

 

통합 테스트

import request from "supertest";
import { createApp } from "../../app";
import { createSlidingWindowLoggingMiddleware } from "../middleware";
import { SlidingWindowLoggingConfig } from "../config";
import type { Express } from "express";

describe("SlidingWindowLogging Integration", () => {
  let app: Express;
  let config: SlidingWindowLoggingConfig;

  beforeEach(() => {
    jest.useFakeTimers();
    config = {
      threshold: 5,
      windowSizeMs: 1000, // 1초 윈도우
    };
    const middleware = createSlidingWindowLoggingMiddleware(config);
    app = createApp({ middlewares: [middleware] });
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it("임계치까지 요청을 허용해야 한다", async () => {
    const responses = [];

    // 5개의 요청 모두 성공해야 함
    for (let i = 0; i < 5; i++) {
      const response = await request(app)
        .get("/api/test")
        .set("X-Forwarded-For", "192.168.1.100");
      responses.push(response);
    }

    responses.forEach((response) => {
      expect(response.status).toBe(200);
      expect(response.body.message).toBe("Test endpoint");
    });
  });

  it("임계치 초과 시 429 응답을 반환해야 한다", async () => {
    // 5개의 요청 성공
    for (let i = 0; i < 5; i++) {
      await request(app)
        .get("/api/test")
        .set("X-Forwarded-For", "192.168.1.100");
    }

    // 6번째 요청은 429 응답
    const response = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", "192.168.1.100");

    expect(response.status).toBe(429);
    expect(response.body.error).toBe("Too Many Requests");
  });

  it("다른 IP 주소는 독립적인 rate limit을 가져야 한다", async () => {
    // IP1에 대해 5개 요청
    for (let i = 0; i < 5; i++) {
      await request(app)
        .get("/api/test")
        .set("X-Forwarded-For", "192.168.1.100");
    }

    // IP2는 여전히 요청 가능
    const response = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", "192.168.1.200");

    expect(response.status).toBe(200);

    // IP1은 더 이상 요청 불가
    const blockedResponse = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", "192.168.1.100");

    expect(blockedResponse.status).toBe(429);
  });

  it("슬라이딩 윈도우가 정확하게 동작해야 한다", async () => {
    const ip = "192.168.1.100";

    // 0ms: 2개 요청
    await request(app).get("/api/test").set("X-Forwarded-For", ip);
    await request(app).get("/api/test").set("X-Forwarded-For", ip);

    // 300ms: 2개 요청
    jest.advanceTimersByTime(300);
    await request(app).get("/api/test").set("X-Forwarded-For", ip);
    await request(app).get("/api/test").set("X-Forwarded-For", ip);

    // 700ms: 1개 요청 (총 5개)
    jest.advanceTimersByTime(400);
    await request(app).get("/api/test").set("X-Forwarded-For", ip);

    // 6번째 요청은 실패
    const blockedResponse = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);
    expect(blockedResponse.status).toBe(429);

    // 1001ms: 처음 2개가 윈도우를 벗어남
    jest.advanceTimersByTime(301);

    // 이제 2개 더 요청 가능
    const response1 = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);
    const response2 = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);

    expect(response1.status).toBe(200);
    expect(response2.status).toBe(200);

    // 다시 임계치에 도달
    const finalBlockedResponse = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);
    expect(finalBlockedResponse.status).toBe(429);
  });

  it("동시 요청을 정확히 처리해야 한다", async () => {
    const ip = "192.168.1.100";

    // 6개의 동시 요청
    const promises = Array(6)
      .fill(null)
      .map(() =>
        request(app)
          .get("/api/test")
          .set("X-Forwarded-For", ip)
          .catch((err) => err.response)
      );

    const responses = await Promise.all(promises);

    // 5개는 성공, 1개는 실패
    const successCount = responses.filter((r) => r.status === 200).length;
    const failCount = responses.filter((r) => r.status === 429).length;

    expect(successCount).toBe(5);
    expect(failCount).toBe(1);
  });

  it("커스텀 키 생성기를 사용할 수 있어야 한다", async () => {
    const customMiddleware = createSlidingWindowLoggingMiddleware(
      config,
      {
        keyGenerator: (req: any) => req.headers["api-key"] || "anonymous",
      }
    );
    const customApp = createApp({ middlewares: [customMiddleware] });

    // 같은 API 키로 5개 요청
    for (let i = 0; i < 5; i++) {
      await request(customApp).get("/api/test").set("api-key", "user-123");
    }

    // 6번째 요청은 실패
    const blockedResponse = await request(customApp)
      .get("/api/test")
      .set("api-key", "user-123");

    expect(blockedResponse.status).toBe(429);

    // 다른 API 키는 성공
    const differentKeyResponse = await request(customApp)
      .get("/api/test")
      .set("api-key", "user-456");

    expect(differentKeyResponse.status).toBe(200);
  });

  it("skip 옵션으로 특정 요청을 제외할 수 있어야 한다", async () => {
    const skipMiddleware = createSlidingWindowLoggingMiddleware(
      config,
      {
        skip: (req: any) => req.headers["skip-rate-limit"] === "true",
      }
    );
    const skipApp = createApp({ middlewares: [skipMiddleware] });

    const ip = "192.168.1.100";

    // 5개의 일반 요청
    for (let i = 0; i < 5; i++) {
      await request(skipApp).get("/api/test").set("X-Forwarded-For", ip);
    }

    // skip 헤더가 있는 요청은 rate limit 무시
    const skipResponse = await request(skipApp)
      .get("/api/test")
      .set("X-Forwarded-For", ip)
      .set("skip-rate-limit", "true");

    expect(skipResponse.status).toBe(200);

    // skip 헤더가 없는 요청은 여전히 차단
    const blockedResponse = await request(skipApp)
      .get("/api/test")
      .set("X-Forwarded-For", ip);

    expect(blockedResponse.status).toBe(429);
  });

  it("onLimitReached 콜백이 호출되어야 한다", async () => {
    const onLimitReached = jest.fn((_req, res) => {
      res.status(429).json({ error: "Too Many Requests" });
    });
    const callbackMiddleware = createSlidingWindowLoggingMiddleware(
      config,
      {
        onLimitReached,
      }
    );
    const callbackApp = createApp({ middlewares: [callbackMiddleware] });

    const ip = "192.168.1.100";

    // 5개의 요청
    for (let i = 0; i < 5; i++) {
      await request(callbackApp).get("/api/test").set("X-Forwarded-For", ip);
    }

    expect(onLimitReached).not.toHaveBeenCalled();

    // 6번째 요청 시 콜백 호출
    await request(callbackApp).get("/api/test").set("X-Forwarded-For", ip);

    expect(onLimitReached).toHaveBeenCalledTimes(1);
    expect(onLimitReached).toHaveBeenCalledWith(
      expect.objectContaining({
        ip: ip,
      }),
      expect.any(Object)
    );
  });

  it("다양한 HTTP 메서드를 지원해야 한다", async () => {
    const ip = "192.168.1.100";

    // 다양한 메서드로 요청
    await request(app).get("/api/test").set("X-Forwarded-For", ip);
    await request(app).post("/api/test").set("X-Forwarded-For", ip);
    await request(app).put("/api/test").set("X-Forwarded-For", ip);
    await request(app).delete("/api/test").set("X-Forwarded-For", ip);
    await request(app).patch("/api/test").set("X-Forwarded-For", ip);

    // 6번째 요청은 메서드와 관계없이 차단
    const response = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);

    expect(response.status).toBe(429);
  });

  it("Fixed Window와 달리 윈도우 경계에서 버스트가 발생하지 않아야 한다", async () => {
    const ip = "192.168.1.100";

    // 윈도우 끝 부분에서 5개 요청
    jest.advanceTimersByTime(900); // 900ms 시점
    for (let i = 0; i < 5; i++) {
      await request(app).get("/api/test").set("X-Forwarded-For", ip);
    }

    // 100ms 후 (새 윈도우 시작)
    jest.advanceTimersByTime(100);

    // Fixed Window와 달리 여전히 5개가 윈도우 내에 있음
    const response = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);

    expect(response.status).toBe(429); // 여전히 차단됨

    // 901ms 더 지나야 첫 요청이 윈도우를 벗어남 (총 1001ms)
    jest.advanceTimersByTime(901);

    // 이제 요청 가능
    const allowedResponse = await request(app)
      .get("/api/test")
      .set("X-Forwarded-For", ip);

    expect(allowedResponse.status).toBe(200);
  });
});

 

 

 

 

Sliding Window Counter

Sliding Window Counter는 Fixed Window Counter와 Sliding Window Logging의 절충안이다.
현재 윈도우와 직전 윈도우의 카운트만 저장해 가중 평균으로 요청률을 추정한다.

 

 

계산 공식

추정 요청 수 = 현재 윈도우 카운트 + (직전 윈도우 카운트 × 겹치는 비율)
  • 현재 윈도우 카운트: 현재 윈도우 내 요청 수
  • 직전 윈도우 카운트: 이전 윈도우 내 요청 수
  • 겹치는 비율: 현재 시점에서 이전 윈도우가 겹치는 비율

 

 

예시


위 그림은 윈도우 크기가 1000ms, 임계치가 10이며 이전 윈도우에서 요청을 8개 처리했고, 현재 윈도우에서 1500ms에서 요청이 3개 들어온 상황이다.

  • 현재 윈도우의 요청 카운터: 3
  • 직전 윈도우의 요청 카운터: 8
  • 직전 윈도우와의 겹치는 비율: 0.5 (1500ms에서 요청이 왔기 때문에, 딱 절반에 해당함)
  • 3 + 8 x 0.5 = 7이므로 1500ms에서는 3개의 요청을 더 허용할 수 있다.

 

 

장점

  • 키당 두 개의 숫자만 저장 → 메모리 효율적.
  • 경계 부근에서도 부드럽게 제한 적용 → Fixed Window의 버스트 문제 해결.

 

단점

  • 근사치 기반이라 100% 정확하진 않음. (직전 시간대에 도착한 요청에 대한 계산은 전혀 수행하지 않음)

 

Cloudflare의 기술 블로그에 따르면, 40억 개의 요청에 대한 실험 결과에서 문제 발생 비율은 단 0.003%에 불과했다고 한다.

(그래서 단점이라 보기에 좀 애매할 듯)

cloudflare 기술 블로그 발췌

 

 

 

만들어보기

export interface SlidingWindowCounterConfig {
  threshold: number; // 윈도우 내 허용되는 최대 요청 수
  windowSizeMs: number; // 윈도우 크기 (밀리초)
}
type WindowCounter = {
  count: number;
  windowStart: number;
};

export class SlidingWindowCounterRateLimiter implements RateLimiter {
  private previousWindows: Map<string, WindowCounter> = new Map();
  private currentWindows: Map<string, WindowCounter> = new Map();

  constructor(private config: SlidingWindowCounterConfig) {}

  tryConsume(key: string): void {
    const now = Date.now();
    const currentWindowStart = this.getCurrentWindowStart(now);
    
    this.updateWindows(key, currentWindowStart);
    
    // 현재 요청을 추가한 후의 예상 rate 계산
    const wouldBeRate = this.calculatePotentialRate(key, now, currentWindowStart);
    
    if (wouldBeRate > this.config.threshold) {
      throw new Error(`Rate Limit Exceeded for key: ${key}`);
    }
    
    this.increaseCounter(key);
  }

  private increaseCounter(key: string): void {
    const currentWindow = this.currentWindows.get(key);
    if (!currentWindow) {
      throw new Error(`Window not found for key: ${key}`);
    }
    currentWindow.count++;
  }

  private updateWindows(key: string, currentWindowStart: number): void {
    let currentWindow = this.currentWindows.get(key);
    
    if (!currentWindow || currentWindow.windowStart !== currentWindowStart) {
      if (currentWindow) {
        this.previousWindows.set(key, currentWindow);
      }
      currentWindow = { count: 0, windowStart: currentWindowStart };
      this.currentWindows.set(key, currentWindow);
    }
  }

  private calculatePotentialRate(key: string, now: number, currentWindowStart: number): number {
    const currentWindow = this.currentWindows.get(key);
    if (!currentWindow) {
      return 1; // 윈도우가 없으면 새 요청 1개만 계산
    }

    const previousWindow = this.previousWindows.get(key);
    const previousWindowStart = currentWindowStart - this.config.windowSizeMs;

    // 현재 윈도우 카운트에 1을 추가한 값으로 계산
    let rate = currentWindow.count + 1;

    if (previousWindow && previousWindow.windowStart === previousWindowStart) {
      // 현재 윈도우에서 경과한 시간
      const elapsedInCurrentWindow = now - currentWindowStart;
      // 이전 윈도우와 겹치는 시간
      const overlapTime = this.config.windowSizeMs - elapsedInCurrentWindow;
      // 이전 윈도우와 겹치는 비율
      const overlapRatio = overlapTime / this.config.windowSizeMs;
      // 슬라이딩 윈도우 카운터 공식: 현재 윈도우 + (이전 윈도우 × 겹치는 비율)
      rate = currentWindow.count + 1 + Math.floor(previousWindow.count * overlapRatio);
    }

    return rate;
  }

  private getCurrentWindowStart(now: number): number {
    return Math.floor(now / this.config.windowSizeMs) * this.config.windowSizeMs;
  }

  cleanup(): void {
    const now = Date.now();
    const currentWindowStart = this.getCurrentWindowStart(now);
    const previousWindowStart = currentWindowStart - this.config.windowSizeMs;

    this.previousWindows.forEach((window, key) => {
      if (window.windowStart < previousWindowStart) {
        this.previousWindows.delete(key);
      }
    });

    this.currentWindows.forEach((window, key) => {
      if (window.windowStart < currentWindowStart) {
        this.previousWindows.set(key, window);
        this.currentWindows.delete(key);
      }
    });
  }
}

 

 

 

 

통합 테스트

import request from "supertest";
import { createApp } from "../../app";
import { createSlidingWindowCounterMiddleware } from "../middleware";

describe("Sliding Window Counter Middleware Integration", () => {
  it("rate limit 이하의 요청은 통과", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware({
          threshold: 3,
          windowSizeMs: 1000,
        }),
      ],
    });

    const responses = await Promise.all([
      request(app).get("/"),
      request(app).get("/"),
      request(app).get("/"),
    ]);

    responses.forEach((response) => {
      expect(response.status).toBe(200);
    });
  });

  it("rate limit 초과 시 429 응답", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware({
          threshold: 2,
          windowSizeMs: 1000,
        }),
      ],
    });

    await request(app).get("/").expect(200);
    await request(app).get("/").expect(200);
    await request(app).get("/").expect(429);
  });

  it("서로 다른 IP는 독립적으로 rate limit 적용", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware({
          threshold: 1,
          windowSizeMs: 1000,
        }),
      ],
    });

    await request(app).get("/").set("X-Forwarded-For", "1.1.1.1").expect(200);
    await request(app).get("/").set("X-Forwarded-For", "1.1.1.1").expect(429);
    await request(app).get("/").set("X-Forwarded-For", "2.2.2.2").expect(200);
  });

  it("커스텀 키 생성기 사용", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware(
          {
            threshold: 2,
            windowSizeMs: 1000,
          },
          {
            keyGenerator: (req) => req.headers["api-key"] as string || "anonymous",
          }
        ),
      ],
    });

    await request(app).get("/").set("api-key", "user1").expect(200);
    await request(app).get("/").set("api-key", "user1").expect(200);
    await request(app).get("/").set("api-key", "user1").expect(429);
    await request(app).get("/").set("api-key", "user2").expect(200);
  });

  it("skip 옵션으로 특정 요청 제외", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware(
          {
            threshold: 1,
            windowSizeMs: 1000,
          },
          {
            skip: (req) => req.path === "/health",
          }
        ),
      ],
    });

    await request(app).get("/").expect(200);
    await request(app).get("/").expect(429);
    
    // health 엔드포인트는 rate limit 무시
    await request(app).get("/health").expect(404); // 라우트가 없어서 404지만 429는 아님
  });

  it("커스텀 에러 핸들러 사용", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware(
          {
            threshold: 1,
            windowSizeMs: 1000,
          },
          {
            onLimitReached: (_req, res) => {
              res.status(503).json({
                error: "Custom Error",
                message: "Please slow down",
              });
            },
          }
        ),
      ],
    });

    await request(app).get("/").expect(200);
    
    const response = await request(app).get("/").expect(503);
    expect(response.body).toEqual({
      error: "Custom Error",
      message: "Please slow down",
    });
  });

  it("다양한 HTTP 메서드 지원", async () => {
    const app = createApp({
      middlewares: [
        createSlidingWindowCounterMiddleware({
          threshold: 5,
          windowSizeMs: 1000,
        }),
      ],
    });

    await request(app).get("/").expect(200);
    await request(app).post("/").expect(200);
    await request(app).put("/").expect(200);
    await request(app).delete("/").expect(200);
    await request(app).patch("/").expect(200);
    await request(app).get("/").expect(429);
  });

});

 

 

 

정리

이렇게 세 가지 Window 기반 Rate Limiter 알고리즘을 비교하면 다음과 같다.

알고리즘 메모리 효율 구현 난이도 버스트 방지 정확도
Fixed Window Counter 매우 높음 매우 쉬움 낮음 중간
Sliding Window Logging 낮음 쉬움 높음 높음
Sliding Window Counter 높음 중간 높음 높음(근사치)

 

 

글이 길어져서,  원래 하려고했던 Nest Throttler에서 어떤 알고리즘들을 채택했고 어떻게 커스터마이징했는지는 다음 포스팅에서 해당 시리즈의 마무리로 다뤄보도록 하겠다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록