쿠폰 발급 테스트를 하기 전 사전 준비는 아래와 같다.
- 테이블 생성(사용자, 이벤트, 쿠폰, 토큰, 발급이력) ✅
- 이벤트 생성 API ✅
- Coupon Pool 생성 API ✅
- 쿠폰 발급 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`을 사용해서 에러를 컨트롤러에서 잡아 폼으로 되돌린다.



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초정도 소요됐다.
'프로젝트 > 쿠폰' 카테고리의 다른 글
| [사용자 쿠폰] 기획 (4) | 2025.08.14 |
|---|---|
| [쿠폰] 성능 테스트 및 개선 (JDBC Bulk Insert) (0) | 2025.04.30 |
| [쿠폰] 테스트를 위한 k6 + InfluxDB + Grafana 구성 (1) | 2025.04.30 |
| [쿠폰] 쿠폰 발급 수정 (saveAll, @Transactional) (0) | 2025.04.29 |
| [쿠폰] 쿠폰 발급 (Redis Stream, CustomDbWorker) (0) | 2025.04.29 |