[내일배움캠프 Spring 4기 - 최종 프로젝트] 93일차 TIL - 쿠폰 기능 구현

서예진·2024년 4월 4일
0

오늘의 학습 키워드

쿠폰(Coupon) 기능 구현
Event 와 연관관계 매핑
발행 쿠폰(Issued Coupon) 기능 구현
발행 쿠폰 만료에 대해서

📖 쿠폰 기능 구현


요구사항

  • 선착순 쿠폰이 존재하며 이 쿠폰은 셀러만 등록할 수 있고 등록할 때 쿠폰의 만료일자, 수량 정보를 입력하여 등록해야한다.
  • 이벤트에서는 해당 선착순 쿠폰과 관련된 이벤트를 등록할 수 있다.
  • 예: 해당 선착순 쿠폰에 대한 정보등을 알려주는 이벤트 또는 홍보 이벤트

ERD

Entity.java

Coupon.java

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Coupon {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column
	private LocalDateTime expirationDate;

	@Column
	private int quantity;

	public Coupon(CouponRequest request) {
		this.expirationDate = request.getExpirationDate();
		this.quantity = request.getQuantity();
	}

	public void decrease() {
		this.quantity -= 1;
	}
}
  • quantity 필드의 타입을 int로 정한이유
    • int 타입은 null 값을 가질 수 없으며, 초기화되지 않으면 기본값은 0이고 Integer는 null 값을 가질 수 있다.
    • 또한, int는 원시 타입이기 때문에 메모리 및 성능 면에서 추가적인 오버헤드가 없고 Integer는 객체이므로 추가적인 메모리 및 성능 오버헤드가 있을 수 있다.
    • 따라서, 수량의 특성 상, null 값을 가질 수 없고 메모리 및 성능 면에서 추가적인 오버헤드가 없게 하기 위해 원시타입인 int 타입을 채택했다.
  • 또한, 쿠폰이 생성된 후에 이벤트에 등록이 가능 할 것이라고 판단하여 Event가 Coupon을 참조하게 설계했다.

Dto.java

CouponRequest.java

@Getter
public class CouponRequest {

	@NotNull(message = "쿠폰 만료일을 등록해주세요.")
	private LocalDateTime expirationDate;
	
	@NotNull(message = "쿠폰 수량을 등록해주세요.")
	private int quantity;
}

CouponResponse.java

@Getter
@AllArgsConstructor
public class CouponResponse {

	private Long couponId;
	private LocalDateTime expirationDate;
	private int quantity;

	public static CouponResponse from(Coupon coupon) {
		return new CouponResponse(
			coupon.getId(),
			coupon.getExpirationDate(),
			coupon.getQuantity()
		);
	}
}

Controller.java

CouponController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/coupons")
public class CouponController {

	private final CouponService couponService;

	@PostMapping
	@PreAuthorize("hasAnyRole('ROLE_SELLER')")
	public ResponseEntity<String> createCoupon(@Valid @RequestBody CouponRequest request) {
		Long couponId = couponService.createCoupon(request);

		return ResponseEntity.created(URI.create("/api/v1/events/" + couponId)).build();
	}

	@GetMapping("/{couponId}")
	@PreAuthorize("hasAnyRole('ROLE_SELLER')")
	public ResponseEntity<CouponResponse> getCoupon(@PathVariable Long couponId) {
		return ResponseEntity.ok().body(couponService.getCoupon(couponId));
	}

	@DeleteMapping("/{couponId}")
	@PreAuthorize("hasAnyRole('ROLE_SELLER')")
	public ResponseEntity<Void> deleteCoupon(@PathVariable Long couponId) {
		couponService.deleteCoupon(couponId);
		return ResponseEntity.ok().build();
	}
}

Service.java

CouponServiceImpl.java

@Service
@RequiredArgsConstructor
public class CouponServiceImpl implements CouponService {

	private final CouponRepository couponRepository;

	@Transactional
	public Long createCoupon(CouponRequest request) {
		Coupon coupon = new Coupon(request);
		Coupon savedCoupon = couponRepository.save(coupon);
		return savedCoupon.getId();
	}

	@Transactional(readOnly = true)
	public CouponResponse getCoupon(Long couponId) {
		Coupon coupon = findCoupon(couponId);
		return CouponResponse.from(coupon);
	}

	@Transactional
	public void deleteCoupon(Long couponId) {
		couponRepository.delete(findCoupon(couponId));
	}

	private Coupon findCoupon(Long couponId) {
		return couponRepository.findById(couponId)
			.orElseThrow(() -> new NotFoundException(NOT_FOUND));
	}
}
  • 기본적으로 쿠폰 등록, 조회, 삭제 기능을 구현했다.

📖 Event 와 연관관계 매핑


Event.java

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@SQLDelete(sql = "UPDATE event SET deleted_at=CURRENT_TIMESTAMP where id=?")
@SQLRestriction("deleted_at IS NULL")
public class Event extends BaseEntity {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column
	private String title;

	@Column
	private String content;

	@Column
	private LocalDate openAt;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "seller_id")
	private User user;

	@OneToOne
	@JoinColumn(name = "coupon_id")
	private Coupon coupon;

	public Event(EventRequest request, User user) {
		this.title = request.getTitle();
		this.content = request.getContent();
		this.openAt = request.getOpenAt();
		this.user = user;
	}

	public void update(EventUpdateRequest request) {
		this.title = request.getTitle();
		this.content = request.getContent();
		this.openAt = request.getOpenAt();
	}

	public void addCoupon(Coupon coupon) {
		this.coupon = coupon;
	}
}
  • Event에 제한인원 정보를 가지고 있는 것보다 쿠폰에 수량으로 정보를 가지고 있는 것이 맞다는 판단하여 쿠폰에 수량 컬럼을 넣었기 때문에 Event에서 limitNum 컬럼을 삭제했다.

Dto.java

@Getter
public class EventRequest {

	@NotNull(message = "이벤트를 등록하시려면 제목을 입력하세요.")
	private String title;

	@NotNull(message = "이벤트를 등록하시려면 내용을 입력하세요.")
	private String content;

	@NotNull(message = "이벤트를 등록하시려면 오픈일자를 입력하세요.")
	@DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate openAt;

	private List<EventProductRequest> eventProducts;

	private Long couponId;

}

@Getter
public class EventUpdateRequest {

	@NotNull(message = "이벤트를 등록하시려면 제목을 입력하세요.")
	private String title;

	@NotNull(message = "이벤트를 등록하시려면 내용을 입력하세요.")
	private String content;

	@NotNull(message = "이벤트를 등록하시려면 오픈일자를 입력하세요.")
	@DateTimeFormat(pattern = "yyyy-MM-dd")
	private LocalDate openAt;

}

@Getter
@AllArgsConstructor
public class EventResponse {

	private Long id;
	private String title;
	private String content;
	private LocalDate openAt;
	private Long couponId;
	private List<Long> eventProducts;

	public static EventResponse from(Event event, List<Long> eventProducts) {
		return new EventResponse(
			event.getId(),
			event.getTitle(),
			event.getContent(),
			event.getOpenAt(),
			event.getCoupon().getId(),
			eventProducts
		);
	}
}

EventServiceImpl.java

	@Transactional
	public Long createEvent(EventRequest eventRequest, User user) {

		Event event = new Event(eventRequest, user);
		Event savedEvent = eventRepository.save(event);

		eventRequest.getEventProducts().stream()
			.map(EventProductRequest::getProductId)
			.map(productId -> productRepository.findById(productId)
				.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상품입니다.")))
			.map(product -> new EventProduct(savedEvent, product))
			.forEach(eventProductRepository::save);

		if(eventRequest.getCouponId() != null) {
			Coupon coupon = couponRepository.findById(eventRequest.getCouponId())
				.orElseThrow(() -> new NotFoundException(NOT_FOUND));

			savedEvent.addCoupon(coupon);
		}

		return savedEvent.getId();
	}
  • 이벤트 등록부분에서 쿠폰의 정보도 포함되게 등록하려고 이벤트 등록에 대해서만 쿠폰과 연관지었다.
  • 이벤트 삭제 부분에서 이벤트가 삭제된다고 해서 해당 쿠폰도 삭제되지 않아도 된다고 판단해서 이벤트 삭제는 그냥 이벤트 삭제만 수행되게끔 했다.

📖 발행 쿠폰(Issued Coupon) 기능 구현

요구사항

  • 정해진 수량의 쿠폰을 사용자가 발행하기를 클릭하여 사용자는 해당 쿠폰을 얻을 수 있다.
  • 이 때, 정해진 수량 이상의 발행 쿠폰은 생성되지 못한다.
  • 즉, 쿠폰의 정해진 수량의 수대로 발행 쿠폰을 생성할 수 있다.
  • 발행하기를 클릭했을 시점이 이벤트 등록할 때, 오픈 일자보다 이른 시점이라면 “아직 오픈되지 않았습니다.”라는 메세지가 뜬다.
  • 정해진 수량 이상의 발행 쿠폰을 생성하려고 한다면 “쿠폰이 마감되었습니다.”라는 메세지가 뜬다.
  • 한 유저 당 하나의 쿠폰만 발행할 수 있다. (동일 쿠폰에 한해서)

Entity.java

IssuedCoupon.java

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@SQLRestriction("deleted_at IS NULL")
public class IssuedCoupon extends BaseEntity {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToOne
	@JoinColumn(name = "user_id")
	private User user;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "coupon_id")
	private Coupon coupon;

	public IssuedCoupon(User user, Coupon coupon) {
		this.user = user;
		this.coupon = coupon;
	}

}

Service.java

EventServiceImpl.java

    @Transactional
	public void issueCoupon(Long eventId, Long couponId, User user, LocalDateTime now) {
		Event event = findEvent(eventId);

		if(now.isBefore(event.getCreatedAt())) {
			throw new InvalidCouponException(INVALID_COUPON);
		}

		Coupon coupon = findCoupon(couponId);

		if(coupon.getQuantity() > 0) {
			coupon.decrease();
			IssuedCoupon issuedCoupon = new IssuedCoupon(user, coupon);
			issuedCouponRepository.save(issuedCoupon);
		} else {
			throw new InvalidCouponException(INVALID_COUPON);
		}
	}
  • 쿠폰을 유저에게 발행할 때 유저는 이벤트를 통해 쿠폰 발급에 접근하게 된다.
  • 따라서, 유저의 현재 시점이 이벤트 오픈 일자 이전이면 InvalidCouponException 예외를 던진다.
  • 또한, 쿠폰의 현재 수량이 0 이하 일 경우도 InvalidCouponException을 던진다.
  • 쿠폰의 현재 수량이 남아 있을 때, 쿠폰을 발행하며 쿠폰의 현재 수량은 하나씩 감소되고 IssuedCoupon 객체를 만들어서 저장한다.

📖 발행 쿠폰 만료에 대해서


  • 쿠폰 만료에 대해서는 어떻게 처리하는 게 좋을까?
    • api를 통해 현재 시점이 쿠폰의 만료 시점이후일 경우, 발행 쿠폰을 삭제한다.(Soft Delete) → 이 api를 호출해야지만 쿠폰의 만료처리가 될텐데 과연 현재 모든 쿠폰의 만료 처리가 이렇게 될까 의문이 들었다.
    • 그렇게 방법을 찾던 중 스케줄러를 발견했다.

스케줄러 적용

Scheduler.java

@Slf4j(topic = "Scheduler")
@Component
@RequiredArgsConstructor
public class Scheduler {

	private final IssuedCouponQuery issuedCouponQuery;

	@Scheduled(cron = "0 0 1 * * *") // 매일 새벽 1시
	public void updateIssuedCoupon() throws InterruptedException {
		log.info("발행 쿠폰 만료 처리 실행");
		issuedCouponQuery.deleteExpiredCoupon();
	}
}

IssuedCouponQueryImpl.java

	@Transactional
	public void deleteExpiredCoupon() {
		QIssuedCoupon qIssuedCoupon = QIssuedCoupon.issuedCoupon;
		LocalDateTime now = LocalDateTime.now();

		long count = jpaQueryFactory
			.update(qIssuedCoupon)
			.set(qIssuedCoupon.deletedAt, now)
			.where(qIssuedCoupon.coupon.expirationDate.before(now))
			.execute();
	}
  • soft delete 방법을 채택했기에 update query를 날리게 했다.
profile
안녕하세요

0개의 댓글