그라찌에 9 - 쿠폰 실제 적용

류희수·2024년 9월 2일

이번 시간부터는 이제 발급 받은 쿠폰을 사용자에게 전달해주고
실제로 할인이나 상품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만을 참조하도록 변경하였음


이제 여기서 쿠폰을 어떻게 처리할까 머리속으로 그려봤다.

  1. 쿠폰의 개수는 어떻게 관리하지??
  2. 쿠폰의 종류는 2종류인데 이걸 어떻게 주지?

1번은
관리자가 쿠폰을 생성한다 -> 쿠폰을 N개를 이벤트(현재는 버튼)에 등록한다. (이 과정에서 체크)

2번은 프론트단에서 버튼을 2개 만들어서 누르면 쿠폰을 종류별로 증정하기로 했다. (추후에 이벤트 같은 것으로 교체 예정)

이제 서비스로직으로 가보자!


UserCouponService

@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을 추가하였다.


CouponService

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));
    }

}

블로그에 기록후에 클래스를 쪼개 상품, 할인 쿠폰 서비스 로직을 따로 분리하였음.

암튼 오예~~!


sql 에러

에러가 안날라기 없었는데 생각보다 너무 빨리 만났다.
할인쿠폰을 저장하는 과정에서 에러가 났다.

foreign key constraint fails (grazie.coupon, CONSTRAINT FK9f3v9pqq2gnjurxgwh9noxbuh FOREIGN KEY (product_product_id) REFERENCES product (product_id))

show create table coupon 으로 sql을 한번 봐보자.

엥 product_product_id가 자꾸 생겨나고 있다...
이것저것 삽질하면서 제약조건도 지워보고 다시 만들기도 하다가...!

조인컬럼에 이름을 지정해주지 않아서 그랬었다...! 생각보다 허무? 하게 해결했다. (product의 기본키가 product_id)라 자꾸 다르게 생성되고 있었다!!


UserCouponController

@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 값이 숫자인편이 좋다고 하여 그렇게 설계하였고
추후에 대용량으로 테스트 해볼 예정이다.

profile
자바를자바

0개의 댓글