본문 바로가기
개발

선착순 쿠폰 서비스 개선하기

by 박연호의 개발 블로그 2025. 3. 27.

패스트 캠퍼스 강의로 "네고왕 이벤트 선착순 쿠폰 시스템" 강의를 들으면서 nestjs + bulljs + prisma 조합으로 만들어 보았습니다.

 

 

Coupon은 쿠폰의 종류와 쿠폰의 유효기간, 발급 수량, 발급된 수량을 관리합니다.

CouponIssue는 어떤 유저가 쿠폰을 발급 받았는지를 관리합니다.

model Coupon {
  id      Int      @id @default(autoincrement())
  title   String // 쿠폰명
  totalQuantity Int? // 쿠폰 발급 최대 수량
  issuedQuantity Int // 발급된 쿠폰 수량
  issueStartDate DateTime // 발급 시작 일시
  issueEndDate DateTime // 발급 종료 일시

  couponIssue CouponIssue[]
}

model CouponIssue {
    id      Int      @id @default(autoincrement())
    couponId Int
    coupon Coupon @relation(fields: [couponId],references: [id])
    userId Int @unique
}

 

 

먼저 쿠폰 서비스는 요청(/coupon/issue)을 받으면 아래 로직을 수행하게 됩니다.

 

1. 쿠폰이 발급 가능한 상태인지

  1.     - 발급 수량이 존재하는지 ?
  2.    - 쿠폰의 기간이 유효한지 ?
  3.    - 해당 유저가 이미 쿠폰을 발급받았는지? 

2. 쿠폰 발급

  1. - Coupon 테이블의 issuedQuantity + 1
  2. - CouponIssue에 couponId와 userId를 저장

 

1,2 작업을 정상적으로 수행해야 정상적으로 쿠폰 발급이 됩니다.

 

coupon.controller.ts

@Controller('coupon')
export class CouponController {
  constructor(private readonly couponService: CouponService) {}

  @Post('issue')
  async post(@Body() requestBody: CouponRequestDto) {
    await this.couponService.issueCoupon({
      userId: requestBody.userId,
      couponId: requestBody.couponId,
    });
  }
}

 

 

coupon.controller.ts

@Injectable()
export class CouponService {
  constructor(private readonly prismaService: PrismaService) {}

  issueCoupon = async (params: { userId: number; couponId: number }) => {
    await this.validateCoupon({
      couponId: params.couponId,
      userId: params.userId,
    });

    await this.saveCouponIssue({
      userId: params.userId,
      couponId: params.couponId,
    });
  };

  private saveCouponIssue = async (params: {
    userId: number;
    couponId: number;
  }) => {
    await this.prismaService.couponIssue.create({
      data: {
        userId: params.userId,
        couponId: params.couponId,
      },
    });

    await this.prismaService.coupon.update({
      where: {
        id: params.couponId,
      },
      data: {
        issuedQuantity: {
          increment: 1,
        },
      },
    });
  };

  private validateCoupon = async (params: {
    couponId: number;
    userId: number;
  }) => {
    const { couponId, userId } = params;
    if (!(await this.availableIssueQuantity({ couponId }))) {
      throw new HttpException('모든 쿠폰이 소진되었습니다.', 500);
    }

    if (!(await this.availableCouponDate({ couponId }))) {
      throw new HttpException('정확하지 않은 기한입니다.', 500);
    }

    if (await this.checkUserAlreadyIssued({ userId, couponId })) {
      throw new HttpException('해당 유저는 이미 쿠폰을 발급받았습니다.', 500);
    }
  };

  private checkUserAlreadyIssued = async (params: {
    userId: number;
    couponId: number;
  }): Promise<boolean> => {
    const couponIssue = await this.prismaService.couponIssue.findFirst({
      where: {
        userId: params.userId,
        couponId: params.couponId,
      },
    });

    return couponIssue !== null;
  };

  private availableCouponDate = async (params: {
    couponId: number;
  }): Promise<boolean> => {
    const coupon = await this.findCoupon({
      couponId: params.couponId,
    });

    const now = new Date();
    return coupon.issueStartDate < now && coupon.issueEndDate > now;
  };

  private findCoupon = async (params: {
    couponId: number;
  }): Promise<Coupon> => {
    const coupon = await this.prismaService.coupon.findFirst({
      where: {
        id: params.couponId,
      },
    });

    if (!coupon) {
      throw new HttpException(
        `${params.couponId} 쿠폰이 존재하지 않습니다.`,
        500,
      );
    }

    return coupon;
  };

  private availableIssueQuantity = async (params: {
    couponId: number;
  }): Promise<boolean> => {
    const coupon = await this.findCoupon({ couponId: params.couponId });

    if (coupon.totalQuantity === null) return true;

    return coupon.totalQuantity > coupon.issuedQuantity;
  };
}

 

 

1000명이 동시에 요청을 보내는 조건으로 locust 부하테스트를 하며 RPS를 확인해 보겠습니다. 또한 docker와 grafana를 통해 서버의 cpu 사용량이 어느정도 되는지도 같이 확인해보겠습니다.

 

mysql와 쿠폰 서버의 cpu는 각각 29%, 36%정도로 크게 문제되지 않습니다. locust 결과를 보면 RPS는 580정도가 나오고 있으며 P50와 P95도 각각 170ms, 390ms로 빠른 응답속도를 보이고 있습니다.

 

 

하지만 문제는 2000개의 쿠폰만 발급해야 하지만, 그보다 더 많은 쿠폰이 발급되었습니다. 이는 로직상에 문제가 있기 때문에 이 부분을 확인해 보겠습니다. 

 

쿠폰 발급 서버는 요청을 받고 아래 로직을 수행하게 됩니다. validateCoupon은 쿠폰 여유 발급 수량 확인 이외에도 다른 작업을 하지만, 핵심 로직이 아니기떄문에 제외했습니다.

 

  1. validateCoupon
    - 쿠폰 여유 발급 수량 확인(totalQuantity > issuedQuantity)

  2. saveCouponIssue
    - CouponIssue row 생성
    -  issuedQuantity + 1후 저장

쿠폰의 총 발급 가능한 수량(totalQuantity)이 500이고, 현재 발급된 수량(issuedQuantity)은 499라고 했을 때 A,B 요청이 왔다고 가정하겠습니다.

 

A, B 요청을 각각 validateCoupon이 실행되며 모두 500 > 499이기 때문에 두 요청 모두 쿠폰을 발급받을 자격이 있게 됩니다. 이후 

saveCouponIssue를 수행하면서 두 요청 모두 쿠폰을 발급받게 됩니다. 이후 C, D 요청이 왔을 때는 발급된 쿠폰 수량이 500개 이기 때문에 validateCoupon를 통과하지 못하게 됩니다.

 

문제는 발급된 쿠폰의 수량을 +1하기 전에 쿠폰의 수량을 조회하는 것이 문제이기 때문에 "쿠폰 수량을 검증하고, 쿠폰을 발급하는 부분"을 임계영역으로 지정하여 동시성처리를 할 수 있습니다.

 


동시성 제어

 N번째 요청이 issuedQuantity값을 읽고 쿠폰 발급 후 issuedQuantity+1된 값을 N+1 요청이 읽어야 하는데 그러지 않고 동시에 issuedQuantity값을 읽기 때문에 문제 입니다. 따라서 issuedQuantity값에 접근하는 순서를 제어해주는 방식으로 문제를 해결해볼 것 입니다.

 

1. 비관락(Pessimistic Lock)

Coupon 레코드에 rock을 걸어놓고 쿠폰발급을 하면서 issuedQuantity+1 하고 난 후 rock을 해제합니다. 이후 다른 트랜잭션 요청이 조회하는 issuedQuantity 값은 +1된 값을 조회하게 됩니다. 이 방법은 Coupon 레코드를 조회할 때 select 절 이후에 for update를 사용하면 됩니다.

 

prisma에서는 Interactive transactions를 사용했기 때문에 트랜잭션 내부에서 사용하는 prisma client는 콜백 함수의 prisma client 인자를 사용해야 합니다.

  issueCoupon = async (params: { userId: number; couponId: number }) => {
    await this.prismaService.$transaction(async (tx) => {
      await this.validateCoupon({
        couponId: params.couponId,
        userId: params.userId,
        prisma: tx,
      });
      await this.saveCouponIssue({
        userId: params.userId,
        couponId: params.couponId,
        prisma: tx,
      });
    });
  };
  
  ...
  
    private availableIssueQuantity = async (params: {
    couponId: number;
    prisma: Prisma.TransactionClient;
  }): Promise<boolean> => {
    const result = await params.prisma.$queryRaw<
      {
        id: number;
        title: string;
        totalQuantity: number;
        issuedQuantity: number;
        issueStartDate: Date;
        issueEndDate: Date;
      }[]
    >`select * from Coupon where id=${params.couponId} for update`;

    return result[0].totalQuantity > result[0].issuedQuantity;
  };

 

 

 

동시성 제어 처리 전 후 DB cpu 사용량은 54% -> 26%으로 변했습니다. 비관적 락을 처리했기 때문에 DB에 부하가 덜 걸린것으로 판단 됩니다. 이는 곧, 전체적인 처리량 저하를 의미하기 때문에 RPS가 579 -> 362로 감소 P50역시 170ms에서 1200ms으로 증가한 것을 확인할 수 있습니다. 

 

이전에는 쿠폰 발급이 더 많이 발급되는 코드였기 때문에 문제가 있었고, 현재 수정된 코드는 쿠폰이 정상적으로 20000개가 발급되었습니다.

 

 

2. redis semaphore

두번째 방법은 redis를 사용하여 임계영역(쿠폰 잔여 수량을 확인 + 쿠폰 발급)에 들어가기 전에 redis lock을 사용하는 방법입니다.

redis-semaphore를 사용하여 임계영역 코드 진입 전 acquire() 메서드로 세마포어를 획득 후, 임계영역을 나가면서 세마포어를 반납하는 구조 입니다.

  issueCoupon = async (params: { userId: number; couponId: number }) => {
    const mutex = new Mutex(this.redis, 'couponKey', {
      acquireTimeout: 100000,
    });
    try {
      await mutex.acquire();
      await this.validateCoupon({
        couponId: params.couponId,
        userId: params.userId,
      });
      await this.saveCouponIssue({
        userId: params.userId,
        couponId: params.couponId,
      });
    } finally {
      await mutex.release();
    }
  };

 

acquiure 메서드는 세마포어를 획득하는 메서드인데 SET NX를 성공하기 전까지 계속 busi waiting을 하게 되며, SET을 완료하여 true를 반환하여 임계영역에 진입하게 됩니다.

 

release 메서드는 acquire에서 SET key를 제거하며, 2개의 redis 연산을 하나의 트랜잭션으로 처리하기 위해 lua scription로 작성했습니다. 아무래도 atomic 연산을 보장하기 위해 이런식으로 코드를 작성한 것 같습니다.

 

 

비관적 락과 마찬가지로 redis를 사용하여 동시성 처리했을 때도 부하 테스트를 진행해 보겠습니다.

 

 

먼저 redis를 사용하여 lock을 구현했기 때문에 db cpu가 13%으로 mysql을 사용했을 때(26%) 보다 적은 수치가 나왔습니다. 가장 큰 차이점을 보이는 것은 RPS와 P50인데 아래와 같이 큰 차이를 보이고 있습니다.

 

mysql -> P90 : 1400ms, P50 : 1200ms

redis -> P90 : 3100ms, P95 : 30000ms 

 

응답속보다 mysql보다 느리다 보니 RPS 역시 362 -> 119로 감소되었습니다. 

추가적으로 테스트 진행중에 postman으로 /coupon/issue 요청을 보내니 응답 시간이 8.71s이 찍히는 것을 확인할 수 있었습니다.

 

쿠폰은 20000개 정상 발급되었습니다.


Redis, bulljs로 처리량 높히기

 

지금까지 만들었떤 쿠폰 발급 서버의 구조를 생각해보면, 많은 요청을 받아 정확한 쿠폰수를 발급하기 위해 임계영역을 지정하여 쿠폰을 발급하였습니다. 임계영역에서는 DB에서 쿠폰을 조회하여 검증 한 후, CouponIssue를 생성하여 DB에 삽입하고, Coupon의 issueQuantity를 +1하였습니다. 즉, 요청을 받으면 쿠폰 발급과정까지 처리해 주었습니다. 

 

하지만, 좀 더 생각해보면 사용자는 쿠폰을 받고 바로 사용하지 않을 것이기 때문에 요청이 오면 "쿠폰을 발급 성공 여부"만 확인해주면 됩니다. 사용자에게는 쿠폰 발급 성공 여부만 응답으로 보내고, 서버에서 "쿠폰 발급할 유저 저장"한 다음에 이후 다른 서버에서 저장된 유저의 쿠폰을 발급하면 될 것입니다.

 

여기서 "쿠폰 발급한 유저 저장" 하는 용도로 queue를 사용할 것입니다. 

 

쿠폰 발급 요청이 오면, 쿠폰 발급 대상자를 queue에 넣고 성공 여부를 응답으로 보냅니다. 이후 쿠폰 발급을 처리하는 서버에서는 queue에 있는 데이터를 읽어 실제 DB에 저장합니다. 여기서 queue는 bulljs를 사용하겠습니다.

쿠폰 발급서버에서 쿠폰 발행 서버에서 유저의 쿠폰을 DB에 삽입하기 때문에, 쿠폰 발급서버에서는 쿠폰 검증만 확인하면 됩니다. 때문에 DB대신 redis를 사용하여 쿠폰 검증을 처리할 것입니다.

 

쿠폰 발급 서버

- 유저가 이미 쿠폰을 발급 받았는지 검증

- 쿠폰의 유효기간 검증

- 쿠폰 잔여 수량 검증

 

쿠폰 발행 서버

- queue에 있는 쿠폰을 가져와 DB에 삽입

 

 

쿠폰 발급 서버에서 유저가 이미 쿠폰을 발급 받았는지 검증할 수 있는 방법을 생각해보면...유저는 쿠폰을 한번만 발급받을 수 있고 언제 받았는지는 관심 없습니다. 선착순 안에 포함되는지 여부만 확인할 수 있으면 됩니다.

 

redis set을 사용하면, 기능을 구현할 수 있는데 set은 중복을 허용하지 않기 때문에 유저의 중복 쿠폰 발급을 막을 수 있습니다. 또한 set의 길이는 이미 쿠폰을 발급받은 유저이기 때문에, 쿠폰의 잔여수량 검증 즉 해당 유저가 현재 쿠폰 발급을 받을 수 있는지 확인할 수 있습니다.

 

먼저 쿠폰 정보를 Hash에 저장합니다. 이 값은 "쿠폰의 유효기간 검증"와 "쿠폰 잔여 수량 검증"에서 사용됩니다.

 

유저가 이미 쿠폰을 발급 받았는지 검증

쿠폰을 발급받은 유저 id는 set에 저장되기 때문에, 해당 유저 id가 set에 저장되어 있는지 여부로 쿠폰 발급 받았는지를 확인할 수 있습니다.

  private checkAlreadyIssued = async (params: { userId: number }) => {
    return await this.redis.sismember(this.couponSet, params.userId);
  };

 

쿠폰의 유효기간 검증

moment로 해당 기간안에 쿠폰의 시작/종료 날짜가 포함되는지 검사합니다.

  private availableCouponDate = async (): Promise<boolean> => {
    const now = moment();
    const { issueStartDate, issueEndDate } = await this.redis.hgetall("coupon");

    return now.isBetween(moment(issueStartDate), moment(issueEndDate));
  };

 

쿠폰 잔여 수량 검증

set에는 쿠폰을 발급받은 유저가 저장되어 있기 때문에 set의 크기가 쿠폰 발급 최대수량 보다 적으면 쿠폰 발급할 수 있습니다.

  private availableIssueQuantity = async (): Promise<boolean> => {
    const totalQuantity = await this.redis.hget("coupon", "totalQuantity");

    return (await this.redis.scard(this.couponSet)) < parseInt(totalQuantity);
  };

 

 

쿠폰 발급하기

쿠폰 검증을 하면 set에 유저의 id와 queue에 유저와 쿠폰 정보를 넣습니다. consumer에서 queue의 데이터를 가져 간후 redis에서 해당 데이터를 삭제하기 위해 removeOnComplete를 추가하였습니다.

  saveCouponIssue = async (params: { userId: number; couponId: number }) => {
    await this.redis.sadd(this.couponSet, params.userId);
    
    (await BullMq.getBullMq()).add(
      this.couponQueue,
      { userId: params.userId, couponId: params.couponId },
      {
        removeOnComplete: true,
      }
    );
  };

 

기존에 mysql을 사용하여 쿠폰발급을 하였을 때, RPS가 135였는데 redis를 사용하니 RPS가 827까지 올랐습니다. 대략 5~6배의 성능 차이를 보이고 있습니다. 

 

하지만 쿠폰은 최대 발급 수량이 10000개보다, 58개 더 발급 되었습니다. 이는 mysql와 같은 문제로 redis는 single thead로 처리하지만, redis로 요청을 보내는 로직에서 동시성 제어가 처리되지 않아서 발생하는 문제입니다. 

 

 

마찬가지로 redis 쿠포발급하는 임계영역도 동시성 처리를 해줍니다.

app.post("/issueCouponV2", async (req: any, res: any, next) => {
  const mutex = new Mutex(await Redis.getRedisClient(), "couponKey", {
    acquireTimeout: 100000,
  });

  try {
    const redisCouponService = new RedisCouponIssueService(
      await Redis.getRedisClient()
    );

    await mutex.acquire();
    await redisCouponService.issueCoupon({
      userId: req.body.userId,
      couponId: req.body.couponId,
    });

    return res.status(200).json({ message: "쿠폰 발급 성공" });
  } catch (err) {
    return next(err);
  } finally {
    await mutex.release();
  }
});

 

 

확실히 동시성 제어를 하니, RPS가 827 -> 179로 떨어진 것을 확인할 수 있습니다. 마찬가지로 RPS가 떨어지니, redsi cpu 사용량도 줄었습니다.

 

기존 mysql 쿠폰 발급의 RPS 135에 비해 34정도 향상된 모습을 볼 수 있습니다.

 

쿠폰 발급 서버에서 queue에 쿠폰을 삽입하면 쿠폰 발행 서버에서는 queue에 있는 데이터를 읽어와 db에 삽입할 수 있습니다. 실제 쿠폰을 발행하는 서버는 백그라운드로 돌리고 쿠폰 발급서버는 클라이언트의 요청을 처리함으로써, 쿠폰을 발급하는 서버의 부하를 분산시킬 수 있습니다.

import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
import mysql from "mysql2/promise";

(async () => {
  const redis = new IORedis({
    host: "localhost",
    port: 6379,
    maxLoadingRetryTime: null,
    maxRetriesPerRequest: null,
  });

  new Worker(
    "Coupon",
    async (job) => {
      const connection = await mysql.createConnection({
        host: "localhost",
        user: "root",
        port: 3306,
        password: "xxxx",
        database: "xxxx",
      });

      const data = job.data as { userId: number; couponId: number };
      await connection.execute(
        `insert into CouponIssue(couponId, userId) values(${data.couponId}, ${data.userId})`
      );

      await connection.end();
    },
    { connection: redis }
  );
})();