Tiny Star

프로젝트/쿠폰

[사용자 쿠폰] 이벤트 쿠폰 생성 (DB, Redis)

하얀지 2025. 8. 22. 14:44

쿠폰 발급 테스트를 하기 전 사전 준비는 아래와 같다.

  1. 테이블 생성(사용자, 이벤트, 쿠폰, 토큰, 발급이력) ✅ 
  2. 이벤트 생성 API ✅
  3. Coupon Pool 생성 API ✅
  4. 쿠폰 발급 API

쿠폰 발급만 테스트할거라서 이벤트 및 쿠폰풀 API는 필요하지 않으나, "그래도 하는김에..!"라는 생각으로 만들었다. 이번 포스팅은 완료한 세 가지에 대해 가볍게 기록하려고 한다.

 


 

 

1. 테이블 생성

-- 1) 사용자
CREATE TABLE `user` (
  `id`            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `email`         VARCHAR(255)    NOT NULL,
  `phone`         VARCHAR(20)     NULL,
  `status`        ENUM('ACTIVE','BLOCKED') NOT NULL DEFAULT 'ACTIVE',
  `created_at`    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_email` (`email`),
  KEY `ix_user_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2) 이벤트
CREATE TABLE `event` (
  `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`       VARCHAR(200)    NOT NULL,
  `starts_at`  DATETIME        NOT NULL,
  `ends_at`    DATETIME        NOT NULL,
  `quota`      INT             NOT NULL,
  `is_active`  TINYINT(1)      NOT NULL DEFAULT 1,
  `created_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `ix_event_active_period` (`is_active`,`starts_at`,`ends_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3) 토큰
CREATE TABLE `notif_token` (
  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `jti`         CHAR(26)        NOT NULL,                -- ULID 등 고유 토큰 ID
  `user_id`     BIGINT UNSIGNED NOT NULL,
  `event_id`    BIGINT UNSIGNED NOT NULL,
  `issued_at`   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `expires_at`  DATETIME        NOT NULL,
  `clicked_at`  DATETIME        NULL,
  `consumed_at` DATETIME        NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_notif_token_jti` (`jti`),
  KEY `ix_notif_token_user_event_consumed` (`user_id`,`event_id`,`consumed_at`),
  KEY `ix_notif_token_expires` (`expires_at`),
  CONSTRAINT `fk_notif_token_user`  FOREIGN KEY (`user_id`)  REFERENCES `user`(`id`)  ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `fk_notif_token_event` FOREIGN KEY (`event_id`) REFERENCES `event`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 4) 쿠폰 재고 풀
CREATE TABLE `coupon_pool` (
  `id`       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `event_id` BIGINT UNSIGNED NOT NULL,
  `code`     CHAR(26)        NOT NULL,   -- 서버 코드(ULID)
  `is_used`  TINYINT(1)      NOT NULL DEFAULT 0,
  `used_by`  BIGINT UNSIGNED NULL,
  `used_at`  DATETIME        NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_coupon_pool_code` (`code`),
  KEY `ix_coupon_pool_event_unused` (`event_id`,`is_used`),
  CONSTRAINT `fk_coupon_pool_event` FOREIGN KEY (`event_id`) REFERENCES `event`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `fk_coupon_pool_used_by` FOREIGN KEY (`used_by`) REFERENCES `user`(`id`) ON DELETE SET NULL ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 5) 쿠폰 발급 이력
CREATE TABLE `coupon_issue` (
  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id`     BIGINT UNSIGNED NOT NULL,
  `event_id`    BIGINT UNSIGNED NOT NULL,
  `code`        CHAR(26)        NOT NULL,
  `jti`         CHAR(26)        NOT NULL,
  `status`      ENUM('SUCCESS','DUPLICATE_BLOCKED','FAILED','ROLLED_BACK') NOT NULL DEFAULT 'SUCCESS',
  `error_message` VARCHAR(500)  NULL,
  `created_at`  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_coupon_issue_user_event` (`user_id`,`event_id`),  -- 유저당 1장
  UNIQUE KEY `uk_coupon_issue_code` (`code`),                      -- 동일 코드 재사용 금지
  KEY `ix_coupon_issue_event_created` (`event_id`,`created_at`),
  CONSTRAINT `fk_coupon_issue_user`  FOREIGN KEY (`user_id`)  REFERENCES `user`(`id`)  ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `fk_coupon_issue_event` FOREIGN KEY (`event_id`) REFERENCES `event`(`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `fk_coupon_issue_code`  FOREIGN KEY (`code`)     REFERENCES `coupon_pool`(`code`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `fk_coupon_issue_jti`   FOREIGN KEY (`jti`)      REFERENCES `notif_token`(`jti`)  ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

토큰(`notif_token`)과 쿠폰 발급 이력(`coupon_issue`)은 이번 포스팅에서 사용하지 않는다. 사용자 쿠폰 발급할 때 사용될 예정이다.

 

 

2. 이벤트 생성 API

이벤트 생성 페이지

이름, 날짜, 쿠폰개수를 지정하여 이벤트를 생성할 수 있도록 했다. 쿼터는 쿠폰 개수로 나중에 몇 개의 쿠폰이 발급될지 따로 지정하지 않아도 이벤트 테이블 조회해서 사용할 수 있다.

 

<form:form method="post" modelAttribute="form" action="/events" >
    <div>
        <label>이름</label>
        <form:input path="name"/>
        <form:errors path="name"/>
    </div>
    <div>
        <label>시작</label>
        <form:input path="startsAt" type="datetime-local"/>
        <form:errors path="startsAt"/>
    </div>
    <div>
        <label>종료</label>
        <form:input path="endsAt" type="datetime-local"/>
        <form:errors path="endsAt"/>
        <form:errors path="endAfterStart" element="div"/>
    </div>
    <div>
        <label>쿼터</label>
        <form:input path="quota" type="number" min="1"/>
        <form:errors path="quota"/>
    </div>

    <button type="submit">생성</button>
</form:form>

`<from:errors>` 태그를 사용해서 서버에서 자동으로 에러 체크 후 반환하고 표시할 수 있도록 했다.

 

private int id;

@NotBlank(message = "이름은 필수입니다.")
private String name;

@NotNull(message = "시작 시각은 필수입니다.")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") // <input type="datetime-local">과 매칭
private LocalDateTime startsAt;

@NotNull(message = "종료 시각은 필수입니다.")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm")
private LocalDateTime endsAt;

@Min(value = 1, message = "쿼터는 1 이상이어야 합니다.")
private int quota;

@AssertTrue(message = "종료 시각은 시작 시각 이후여야 합니다.")
public boolean isEndAfterStart() {
    if (startsAt == null || endsAt == null) return true;
    return !endsAt.isBefore(startsAt);
}

DTO에 `validation` 지정해두고 값이 들어있지 않거나, `isEndAfterStart()` 같이 프론트에서 통과되더라도 백엔드에서 한 번 더 방어할 수 있도록 했다.

 

@PostMapping
public String create(@Valid @ModelAttribute("form") RequestEventForm form, BindingResult bindingResult
        , RedirectAttributes redirectAttributes) {
    if (bindingResult.hasErrors()) {
        return "event/new";
    }
    int eventId = eventService.createEvent(form);
    redirectAttributes.addFlashAttribute("created", true);
    return "redirect:/events/" + eventId;
}

`BindingResult`을 사용해서 에러를 컨트롤러에서 잡아 폼으로 되돌린다.

 

name / quata / isEndAfterStart()

 

 

3. Coupon Pool 생성 API 

이벤트 쿠폰 생성 페이지

특정 이벤트의 쿠폰만 발급할 수 있도록 관리하는 페이지이다. select에는 발급되지 않는 쿠폰만 나오고 하단 테이블에서 전체 이벤트를 볼 수 있다. 쿠폰 생성여부는 `event` 테이블 조회 시 서브 쿼리로 `coupon_pool` 테이블에 `event_id` 여부를 체크하는 것으로 했다.

 

@Transactional
public void generateCouponPool(long eventId) {
    EventView ev = eventMapper.findById((int) eventId);
    int quota = ev.getQuota();
    if (quota <= 0) {
        throw new IllegalArgumentException("count must be positive");
    }

    final int CHUNK = 1000;

    List<String> buffer = new ArrayList<>(CHUNK);
    for (int i = 0; i < quota; i++) {
        buffer.add(newUlid());
        if (buffer.size() == CHUNK) {
            couponPoolMapper.insertBatchIgnore(eventId, buffer);
            enqueueToRedis(eventId, buffer);
            buffer.clear();
        }
    }
    if (!buffer.isEmpty()) {
        couponPoolMapper.insertBatchIgnore(eventId, buffer);
        enqueueToRedis(eventId, buffer);
    }
}

private void enqueueToRedis(long eventId, List<String> codes) {
    String listKey = REDIS_COUPON_STOCK + eventId;
    redis.opsForList().rightPushAll(listKey, codes);
}

private String newUlid() {
    return UlidCreator.getUlid().toString();
}

`insert coupon_pool`와 `Redis push`를 동시에 작업하도록 했다. 10만건 이상 1000개씩 끊어서 인서트하도록 했다. Redis 포함 10만건 이상 넣는데 3초정도 소요됐다.

 

 


 

[이벤트 코드]

[쿠폰 풀 코드]

 

 

 

top