콘텐츠로 이동

쿠폰

결제 없이 멤버십을 발급하는 단일 사용(single-use) 토큰. 어드민이 상품 유형(STANDARD/PREMIUM)을 지정해 일괄 발급하고, 사용자가 코드를 입력하면 즉시 해당 티어의 멤버십이 생성된다. 쿠폰은 한 번 사용되면 영구히 USED 상태로 고정되며, 만료일·할인율 같은 부가 속성은 없다.

사용자 여정

어드민 쿠폰 일괄 발급

sequenceDiagram
    autonumber
    participant ADMIN as 어드민
    participant FE as 어드민 콘솔
    participant API as Backend (AdminController)
    participant SVC as AdminService
    participant DB as MySQL

    ADMIN->>FE: 쿠폰 발급 (productType=PREMIUM, amount=50)
    FE->>API: POST /admin/coupons
{ productType, amount } API->>SVC: generateCoupon(productType, amount) loop amount회 반복 SVC->>SVC: Coupon.createRandom(productType)
(UUID 앞 5자리, 대문자) end SVC->>DB: couponRepository.saveAll(coupons) API-->>FE: 200 OK Note over ADMIN: 발급된 코드는 GET /admin/coupons로 조회
(사용 여부 + 사용한 계정 정보 포함)

사용자 쿠폰 등록 → 멤버십 발급

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (AccountController)
    participant SVC as AccountService
    participant DB as MySQL

    U->>FE: 쿠폰 코드 입력 (예: "A3F9C")
    FE->>API: POST /accounts/coupons
{ code } API->>SVC: useCoupon(accountId, code) SVC->>DB: couponRepository.findByIdOrNull(code) alt 코드 없음 SVC-->>FE: { result: INVALID } else 이미 사용됨 SVC-->>FE: { result: USED } else 정상 SVC->>SVC: account.initMembershipByCoupon(coupon) Note over SVC: 1) Coupon.use() — status=USED, usedTime=now
2) Membership.byCoupon — 오늘부터 30일 SVC->>DB: INSERT membership (productType→membershipType) SVC->>DB: UPDATE coupon SET status=USED SVC-->>FE: { result: OK } end

어드민 쿠폰 삭제 (미사용 코드 회수)

sequenceDiagram
    autonumber
    participant ADMIN as 어드민
    participant API as Backend (AdminController)
    participant SVC as AdminService
    participant DB as MySQL

    ADMIN->>API: DELETE /admin/coupons/{code}
    API->>SVC: deleteCoupon(code)
    SVC->>DB: couponRepository.findByIdOrThrow(code)
    alt 이미 사용됨
        SVC-->>ADMIN: AlreadyUsedCouponException (4xx)
    else 미사용
        SVC->>DB: couponRepository.delete(coupon)
        SVC-->>ADMIN: 200 OK
    end

백엔드 구현

계층 클래스 / 파일 역할
Controller (사용자) AccountController (apps/backend/.../controller/AccountController.kt) POST /accounts/coupons — 코드 입력 + 멤버십 발급
Controller (어드민) AdminController (apps/backend/.../controller/AdminController.kt) /admin/coupons/** — 발급·조회·삭제
Service AccountService.useCoupon() (apps/backend/.../application/service/account/AccountService.kt) 코드 검증 → Account.initMembershipByCoupon() 위임
Service AdminService.generateCoupon/getAllCouponList/deleteCoupon (apps/backend/.../application/service/admin/AdminService.kt) 일괄 발급, 페이지 조회, 삭제
Domain (Entity) Coupon (apps/backend/.../application/domain/product/Coupon.kt) code (PK), productType, status, usedTime + use() / isValid() / createRandom()
Domain (Enum) CouponStatus UNUSED / USED
Domain (Entity) Membership.byCoupon() 쿠폰 기반 멤버십 생성 — payment=null, coupon=this
Repository CouponRepository (apps/backend/.../persistence/CouponRepository.kt) JpaRepository
Repository CouponQuerydslRepository(Impl) (apps/backend/.../persistence/CouponQuerydslRepository*.kt) 어드민 페이지 쿼리

도메인 규칙

규칙 위치 값 / 설명
코드 형식 Coupon.createRandom() UUID.replace("-","").substring(0,5).uppercase() — 5자리 영숫자 대문자
코드 충돌 처리 (없음) UUID 앞 5자리 추출 — 충돌 가능성 존재 (PK 위반 시 saveAll 전체 실패)
발급 가능 상품 ProductType STANDARD / PREMIUM / TOKEN_PACK 모두 가능 (실무는 STANDARD/PREMIUM 위주)
사용 가능 판정 Coupon.isValid() status != USED (UNUSED만 사용 가능)
사용 처리 Coupon.use() status=USED, usedTime=now(). 이미 USED면 AlreadyUsedCouponException
멤버십 생성 시점 Membership.byCoupon() 사용 시점 기준 오늘부터 30일 (coupon.use() 즉시 호출)
결제 정보 매핑 Membership.byCoupon() payment=null, coupon=this. 마이페이지 표시는 paymentType="쿠폰", paidAmount=0
멤버십 시작일 보정 Account.addMembership() 기존 유료 멤버십 있으면 쿠폰 멤버십 startDate를 maxAfterMembership.endDate+1로 미룸
사용자 응답 분기 CouponUseResponse OK / USED / INVALID 3가지 enum 결과 (예외 throw 대신 결과값 반환)
만료일 (없음) 쿠폰 자체엔 발급일·만료일 개념 없음. 발급 후 영구 유효
어드민 삭제 제약 AdminService.deleteCoupon() 이미 USED면 삭제 불가 (AlreadyUsedCouponException) — 사용 이력 보존 목적

API 엔드포인트

Method Path 설명
POST /accounts/coupons 사용자 쿠폰 등록 — OK / USED / INVALID 응답
GET /admin/coupons 어드민 쿠폰 목록 페이지 (사용 여부 + 사용 계정 정보 포함)
POST /admin/coupons 쿠폰 일괄 발급 (productType, amount)
DELETE /admin/coupons/{code} 미사용 쿠폰 삭제 (USED는 거부)

스펙 상세(request/response schema)는 API 레퍼런스 / OpenAPI 페이지에서 endpoint별 검색.

데이터 모델

erDiagram
    COUPON {
        string code PK "UUID 앞 5자 대문자"
        string product_type "STANDARD / PREMIUM / TOKEN_PACK"
        string status "UNUSED / USED"
        datetime used_time "nullable — 사용 시 채워짐"
        datetime created_date_time "BaseEntity"
    }
    MEMBERSHIP {
        int membership_id PK
        string account_id FK
        string code FK "nullable — 쿠폰 발급 시 1:1"
        string payment_id FK "nullable — 결제 발급 시"
        string membership_type
        datetime start_date
        datetime end_date "start_date + 30일"
    }
    ACCOUNT {
        string account_id PK
    }
    COUPON ||--o| MEMBERSHIP : "0..1 (사용 시 1:1)"
    ACCOUNT ||--o{ MEMBERSHIP : "1:N"

설정

항목 위치 비고
쿠폰 가격·기간 (Coupon 자체엔 없음) productType이 가리키는 MembershipPlanProperties.plans.*.appliedDays 사용 (모두 30일)
어드민 권한 SecurityConfig + AdminController ADMIN role만 /admin/coupons/** 접근
결제 비용 회계 (해당 없음) 쿠폰 발급은 매출 0원으로 기록 — 마이페이지 paidAmount=0, paymentType="쿠폰"

알려진 이슈 / 개선 예정

  • PK 충돌 가능성: Coupon.createRandom()이 UUID hex 앞 5자리만 사용 — 16^5 ≈ 1M개 공간으로 birthday paradox 임계점(약 ~1200건)을 일반 마케팅 캠페인 수준에서 쉽게 넘김. couponRepository.saveAll()이 단일 트랜잭션이라 1건 PK 충돌 시 전체 rollback. 6-8자리 확장 또는 retry 로직 필요
  • 만료일 부재: 발급된 쿠폰은 영구 유효. 마케팅 캠페인 종료 후 사용 차단 메커니즘 없음 — 별도 admin DELETE 일괄 호출 필요
  • 할인율·금액쿠폰 미지원: 쿠폰은 1개 = 1개월 멤버십 발급. "10% 할인" 같은 결제 보조 쿠폰 도메인 없음
  • TOKEN_PACK 쿠폰 미테스트: Coupon.productType이 TOKEN_PACK일 때 Membership.byCouponMembershipType.valueOf("TOKEN_PACK")을 시도하지만 MembershipType enum엔 TOKEN_PACK이 없음 → 런타임 IllegalArgumentException. 어드민 발급 폼에서 TOKEN_PACK 선택 가능하지만 사용 시 깨짐
  • 사용자 응답이 enum 결과값 (OK/USED/INVALID): HTTP status는 200으로 통일 — 클라이언트가 body를 파싱해 분기해야 함. RESTful하게 4xx로 응답하는 편이 일관성 있음
  • 어드민 발급 이벤트 로그 부재: 누가 언제 몇 개 발급했는지 별도 audit log 없음 (AdminAuditLogController는 다른 엔드포인트용)

관련 문서