이번 시간부터는 이제 발급 받은 쿠폰을 사용자에게 전달해주고
실제로 할인이나 상품id랑 매칭이 되는 지 테스트 해보려고 한다.
CREATE TABLE user_coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_coupon_id BIGINT,
discount_coupon_id BIGINT,
is_used TINYINT(1) DEFAULT 0,
issuedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expirationDate DATE NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (product_coupon_id) REFERENCES product_coupon(id),
FOREIGN KEY (discount_coupon_id) REFERENCES discount_coupon(id)
);
사용자와 쿠폰을 이어줄 userCoupon 테이블 생성하는 과정에서 상품쿠폰, 할인쿠폰을 둘다 집어넣었는데 컬럼하나당 discountCp, productCP 을 둘다 집어넣고 둘 중 하나는 null 로 사용하는 방식을 사용하려 했었다.
근데 뭔가.. 뭔가... 비효율적인거 같은 생각이 들었다.
그래서 구글링을 하다가 JPA의 상속을 사용하기로 하였음...!
(나는 자바에서만 상속만 생각하고 있었다,,,,)
orm?
CREATE TABLE coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
coupon_name VARCHAR(255) NOT NULL,
description VARCHAR(255),
expiration_date DATE NOT NULL,
issue_date DATE NOT NULL
);
일단 할인쿠폰과 상품쿠폰의 부모인 Coupon 테이블을 생성
ALTER TABLE product_coupon
ADD COLUMN coupon_id BIGINT,
ADD FOREIGN KEY (coupon_id) REFERENCES coupon(id);
ALTER TABLE discount_coupon
ADD COLUMN coupon_id BIGINT,
ADD FOREIGN KEY (coupon_id) REFERENCES coupon(id);
할인쿠폰과 상품쿠폰이 쿠폰(부모)를 참조하게 변경
package Grazie.com.Grazie_Backend.coupon.coupon;
import Grazie.com.Grazie_Backend.coupon.Coupon;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Entity
@Table(name = "user_coupon")
@Getter
@Setter
public class UserCoupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@ManyToOne
@JoinColumn(name = "coupon_id", nullable = false)
private Coupon coupon;
@Column(name = "is_used")
private Boolean isUsed = false;
@Column(name = "issued_at")
private LocalDate issuedAt;
@Column(name = "expiration_date", nullable = false)
private LocalDate expirationDate;
public UserCoupon() {
}
public UserCoupon(Long userId, Coupon coupon, Boolean isUsed, LocalDate issuedAt, LocalDate expirationDate) {
this.userId = userId;
this.coupon = coupon;
this.isUsed = isUsed;
this.issuedAt = issuedAt;
this.expirationDate = expirationDate;
}
}
부모인 Coupon 엔티티를 추상클래스로 정의
추상클래스로 공통적인 필드를 상속하고 재사용성을 높였음.
또한 추후에 기프티콘이나, 선물하기등 다른 쿠폰을 추가할 경우를 고려했음.
@MappedSuperclass**는 부모 클래스가 직접 엔티티로 매핑되지 않지만,
그 필드들이 상속받는 자식 클래스에 매핑되도록 하는 데 사용
로 하려고 했는데 공통매핑정보 는 맞지만 userCoupon에서 가져올수가 없었다.
주로 테이블과 관계가 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할을 한다고 한다!!
출처: https://ict-nroo.tistory.com/129 [개발자의 기록습관:티스토리]
@Entity
@DiscriminatorValue("DISCOUNT") // 서브클래스 구별 값 표시
@Getter
@Setter
public class DiscountCoupon extends Coupon {
@Column(nullable = false)
private java.math.BigDecimal discountRate;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
public DiscountCoupon() {
}
public DiscountCoupon(Long id, String couponName, String description, LocalDate expirationDate, LocalDate issueDate, BigDecimal discountRate, Product product) {
super(id, couponName, description, expirationDate, issueDate);
this.discountRate = discountRate;
this.product = product;
}
}
@Entity
@DiscriminatorValue("PRODUCT") // 서브클래스 구별 값
@Getter
@Setter
public class ProductCoupon extends Coupon {
@ManyToOne
@JoinColumn(nullable = false)
private Product product;
public ProductCoupon() {
}
public ProductCoupon(Long id, String couponName, String description, LocalDate expirationDate, LocalDate issueDate, Product product) {
super(id, couponName, description, expirationDate, issueDate);
this.product = product;
}
}
@MappedSuperclass 를 사용했기에 공통 필드는 부모의 정의하고 코드를 정리하였음!!
@Entity와 @Table을 함께 사용하는 클래스가 JPA 상속 전략에 맞지 않기 때문에 발생.
SINGLE_TABLE 전략을 사용할 때는 상속 계층의 루트 클래스에만 @Table을 선언하고, 서브클래스에는 @Table을 선언하지 않아야 함.
-> 자식 엔티티들의 table 컬럼 삭제
why?
JPA의 SINGLE_TABLE 전략을 사용할 때, 모든 서브클래스들이 같은 테이블을 공유하며 이 테이블을 상속 계층의 루트 클래스만 매핑 한다.
이 설정에 따라 서브클래스는 @Table을 사용하지 않고, @DiscriminatorColumn을 통해 구분값을 사용하게된다!
CREATE TABLE user_coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
coupon_id BIGINT NOT NULL,
is_used TINYINT(1) DEFAULT 0, -- 사용 여부 (0: 미사용, 1: 사용됨)
issued_at DATE DEFAULT CURRENT_DATE,
expiration_date DATE NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id),
FOREIGN KEY (coupon_id) REFERENCES coupon(id) -- 요 부분 --
);
);
이제 아까 만들었던 userCoupon 테이블에서 coupon만을 참조하도록 변경하였음
이제 여기서 쿠폰을 어떻게 처리할까 머리속으로 그려봤다.
- 쿠폰의 개수는 어떻게 관리하지??
- 쿠폰의 종류는 2종류인데 이걸 어떻게 주지?
1번은
관리자가 쿠폰을 생성한다 -> 쿠폰을 N개를 이벤트(현재는 버튼)에 등록한다. (이 과정에서 체크)
2번은 프론트단에서 버튼을 2개 만들어서 누르면 쿠폰을 종류별로 증정하기로 했다. (추후에 이벤트 같은 것으로 교체 예정)
이제 서비스로직으로 가보자!
@Service
@RequiredArgsConstructor
public class UserCouponService {
private final UserCouponRepository userCouponRepository;
private final UserRepository userRepository;
private final DiscountCouponRepository discountCouponRepository;
private final ProductCouponRepository productCouponRepository;
@Transactional
public void issueCoupon(Long userId, Long couponId, String couponType) {
User user = getUserById(userId);
Coupon coupon = getCouponByIdAndType(couponId, couponType);
checkAlreadyIssued(user, coupon);
UserCoupon userCoupon = createUserCoupon(user, coupon);
userCouponRepository.save(userCoupon);
}
private Coupon getCouponByIdAndType(Long couponId, String couponType) {
if ("DISCOUNT".equals(couponType)) {
return discountCouponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("해당하는 할인 쿠폰이 없습니다!"));
} else if ("PRODUCT".equals(couponType)) {
return productCouponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("해당하는 상품 쿠폰이 없습니다!"));
} else {
throw new IllegalArgumentException("잘못된 쿠폰 타입입니다.");
}
}
private User getUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("해당하는 유저가 없습니다!"));
}
private void checkAlreadyIssued(User user, Coupon coupon) {
boolean alreadyIssued = userCouponRepository.existsByUserAndCoupon(user, coupon);
if (alreadyIssued) {
throw new IllegalStateException("이미 쿠폰을 발급받으셨습니다!");
}
}
private UserCoupon createUserCoupon(User user, Coupon coupon) {
UserCoupon userCoupon = new UserCoupon();
userCoupon.setUser(user);
userCoupon.setCoupon(coupon);
userCoupon.setIsUsed(false);
userCoupon.setIssuedAt(LocalDate.now());
userCoupon.setExpirationDate(coupon.getExpirationDate());
return userCoupon;
}
}
쿠폰측은 변경 빈도가 잦고 변경할 확률이 매우 높다고 생각하여 메소드 단위를 더 신경써서 세분화하였다!
또한 결제에 관한 부분이기때문에@Transactional을 추가하였다.
public class UserCouponServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private UserCouponRepository userCouponRepository;
@Mock
private DiscountCouponRepository discountCouponRepository;
@Mock
private ProductCouponRepository productCouponRepository;
@InjectMocks
private UserCouponService userCouponService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
@DisplayName("유저에게 할인 쿠폰이 정상적으로 할당 되는지 테스트")
void testIssueDiscountCoupon_Success() {
// Given
Long userId = 1L;
Long couponId = 1L;
String couponType = "DISCOUNT";
User user = new User();
user.setId(1L);
DiscountCoupon discountCoupon = new DiscountCoupon();
discountCoupon.setId(couponId);
discountCoupon.setExpirationDate(LocalDate.now().plusDays(10));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(discountCouponRepository.findById(couponId)).thenReturn(Optional.of(discountCoupon));
when(userCouponRepository.existsByUserAndCoupon(user, discountCoupon)).thenReturn(false); // 아직 발급 안된 상태
userCouponService.issueCoupon(userId, couponId, couponType);
verify(userCouponRepository).save(any(UserCoupon.class));
}
@Test
@DisplayName("이미 할인쿠폰이 발급된 유저에게 다시 발급하려고 할 때 예외 발생")
void testIssueDiscountCoupon_AlreadyIssued() {
// Given
Long userId = 1L;
Long couponId = 1L;
String couponType = "DISCOUNT";
User user = new User();
user.setId(1L);
DiscountCoupon discountCoupon = new DiscountCoupon();
discountCoupon.setId(couponId);
discountCoupon.setExpirationDate(LocalDate.now().plusDays(10));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(discountCouponRepository.findById(couponId)).thenReturn(Optional.of(discountCoupon));
when(userCouponRepository.existsByUserAndCoupon(user, discountCoupon)).thenReturn(true); // 이미 발급된 상태
// When & Then
assertThrows(IllegalStateException.class, () -> userCouponService.issueCoupon(userId, couponId, couponType));
}
@Test
@DisplayName("유저에게 상품쿠폰이 정상적으로 할당 되는지 테스트")
void testIssueProductCoupon_Success() {
// Given
Long userId = 1L;
Long couponId = 1L;
String couponType = "PRODUCT";
User user = new User();
user.setId(1L);
ProductCoupon productCoupon = new ProductCoupon();
productCoupon.setId(couponId);
productCoupon.setExpirationDate(LocalDate.now().plusDays(10));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(productCouponRepository.findById(couponId)).thenReturn(Optional.of(productCoupon));
when(userCouponRepository.existsByUserAndCoupon(user, productCoupon)).thenReturn(false); // 아직 발급 안된 상태
userCouponService.issueCoupon(userId, couponId, couponType);
verify(userCouponRepository).save(any(UserCoupon.class));
}
@Test
@DisplayName("이미 상품 쿠폰이 발급된 유저에게 다시 발급하려고 할 때 예외 발생")
void testIssueProductCoupon_AlreadyIssued() {
// Given
Long userId = 1L;
Long couponId = 1L;
String couponType = "PRODUCT";
User user = new User();
user.setId(1L);
ProductCoupon productCoupon = new ProductCoupon();
productCoupon.setId(couponId);
productCoupon.setExpirationDate(LocalDate.now().plusDays(10));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(productCouponRepository.findById(couponId)).thenReturn(Optional.of(productCoupon));
when(userCouponRepository.existsByUserAndCoupon(user, productCoupon)).thenReturn(true); // 이미 발급된 상태
// When & Then
assertThrows(IllegalStateException.class, () -> userCouponService.issueCoupon(userId, couponId, couponType));
}
}
블로그에 기록후에 클래스를 쪼개 상품, 할인 쿠폰 서비스 로직을 따로 분리하였음.
암튼 오예~~!
에러가 안날라기 없었는데 생각보다 너무 빨리 만났다.
할인쿠폰을 저장하는 과정에서 에러가 났다.
foreign key constraint fails (
grazie.coupon, CONSTRAINTFK9f3v9pqq2gnjurxgwh9noxbuhFOREIGN KEY (product_product_id) REFERENCESproduct(product_id))
show create table coupon 으로 sql을 한번 봐보자.
엥 product_product_id가 자꾸 생겨나고 있다...
이것저것 삽질하면서 제약조건도 지워보고 다시 만들기도 하다가...! 
조인컬럼에 이름을 지정해주지 않아서 그랬었다...! 생각보다 허무? 하게 해결했다. (product의 기본키가 product_id)라 자꾸 다르게 생성되고 있었다!!
@RestController
@RequestMapping("/api/coupons")
@RequiredArgsConstructor
public class UserCouponController {
private final UserCouponService userCouponService;
private final CouponRepository couponRepository;
@GetMapping("/list")
public List<Coupon> getAvailableCoupons() {
return couponRepository.findAll();
}
@PostMapping("/issue")
public ResponseEntity<String> issueCoupon(@RequestParam("id") Long userId, @RequestBody UserCouponDTO couponDTO) {
try {
userCouponService.issueCoupon(userId, couponDTO.getCouponId(), couponDTO.getCouponType());
return ResponseEntity.ok("쿠폰이 성공적으로 발급되었습니다!!");
} catch (IllegalArgumentException | IllegalStateException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}


쿠폰이 발급되면 user_coupon 데이터베이스에 이렇게 들어오게 된다! pk값이..다 숫자라 보기에는 조금 어색하지만 쿠폰같은 건 선착순으로 몇천개 뿌린다고 가정하고 만들었다...! 대용량일수록 pk 값이 숫자인편이 좋다고 하여 그렇게 설계하였고
추후에 대용량으로 테스트 해볼 예정이다.