그라찌에 8 - 할인, 상품 쿠폰(기본적 CRD)

류희수·2024년 8월 28일


두구두구두구 머지해와서 상품들이 생겼다!
본격적으로 개발해보고자 한다!

이제 로그인을 구현했으니 쿠폰을 적용시켜보려고 한다.

쿠폰은 상품쿠폰(ex : 아이스티 한잔) , 가격 쿠폰( ex : 20%할인) 등으로 나누어서 하려고 한다.

User <- userCoupon -> ProductCoupon, DiscountCoupon

  • couponHistory를 생성하여 기록들을 관리해볼 예정

일단 상품, 및 매장이 완전히 개발되고 있지 않아서 먼저 기본적인 툴을 만들어보고 mock을 이용하여 단위 테스트를 먼저 하고 있을 예정이다!



데이터 베이스 생성

CREATE TABLE ProductCoupon (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    couponName VARCHAR(255) NOT NULL, -- 쿠폰 이름
    productName VARCHAR(255) NOT NULL, -- 관련된 상품 이름
    description TEXT, -- 쿠폰 설명
    issueDate DATE NOT NULL, -- 발행 날짜
    expirationDate DATE NOT NULL -- 만료 날짜
);

productCoupon 생성 productName 은 product_id로 변경할 수도 있음.

CREATE TABLE UserCoupon (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    coupon_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    is_used BOOLEAN DEFAULT FALSE,
    issuedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expirationDate DATE NOT NULL,
    UNIQUE(user_id, coupon_id, product_id), -- 사용자, 쿠폰, 상품 조합의 유일성 보장
    FOREIGN KEY (user_id) REFERENCES Users(id),
    FOREIGN KEY (coupon_id) REFERENCES ProductCoupon(id),
    FOREIGN KEY (product_id) REFERENCES Product(id) 
);

what 복합 유니크 제약 조건?

복합 유니크 제약 조건의 의미
유니크 제약 조건 : 데이터베이스에서 특정 컬럼 또는 컬럼 조합의 값이 중복되지 않도록 보장
복합 유니크 제약 조건 : 두 개 이상의 컬럼이 결합된 값이 유일해야 한다는 제약 조건

INSERT INTO product (name, image, price, explanation, size, information, temperature)
VALUES (
   '아이스티', 
   'http://example.com/image.jpg', 
   3000, 
   '시원하고 좋아유 ', 
   'medium',
   '{"calories":200, "saturatedFat":5, "protein":10, "sodium":150, "sugar":10, "caffeine":50}', 
   'ice'
);

아이스티 임시 데이터로 넣어 테스트
중간에 json 배열로
@JdbcTypeCode(SqlTypes.JSON)
private ProductInformation information;
상품 영양정보를 넣은건 좋은 코드인거 같다 !


쿠폰의 테스트 로직

  1. 쿠폰 생성하기
  2. 사용자에게 쿠폰 발급
  • 사용자가 보유 쿠폰함에서 쿠폰을 확인할 수 있게
  1. 쿠폰 사용하기 (이게 아직 결제가 안되서 좀 애매함)

@Entity
@Table(name = "Product_Coupon")
@Getter
@Setter
public class ProductCoupon {

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

    @Column(nullable = false)
    private String couponName;

    @Column
    private String description;

    @Column(nullable = false)
    private LocalDate issueDate;

    @Column(nullable = false)
    private LocalDate expirationDate;

    @ManyToOne
    @JoinColumn(name = "product_id", nullable = false)
    private Product product; // Product 엔티티와 연관관계 설정

일단 entity먼저 만들었다.
product 와 연관관계를 맺어서 상품에 대한 쿠폰을 발급받도록!
또한 JPA 상속받은 Repository는 생략하도록 하겠다


ProductCoupon 이 Product 가 있을 때에만 값이 들어가게 참조하도록 변경

ALTER TABLE UserCoupon
     ADD CONSTRAINT fk_coupon
     FOREIGN KEY (coupon_id) REFERENCES Product_Coupon(id);

제품쿠폰에 id(유니크값, 불변!)을 참조하도록 변경

UPDATE Product_Coupon pc
    -> JOIN Product p ON pc.product_Name = p.name
    -> SET pc.product_id = p.product_id;
Query OK, 0 rows affected (0.01 sec)

제품 쿠폰에서 조인을 통해서 제품이름을 업데이트 하도록 변경!


이제 product_coupon에 저장할 때 product_name이 필요할까 싶었다...

상품 쿠폰인데 상품이름이 없는게 맞나?
-> 근데 어처피 id로 조인하잖아! 굳이 없어도 되지!
-> 아 그래도.. 상품쿠폰에 상품이름이 없어?!

라고 바보 같은 고민을 나름 조금 하고 있었다...

계속 고민하다가 나온 결론은

백엔드 특히 디비는 직관성을 생각할 필요가 없을 거 같다! 가 내 결론이다! 어처피 프론트에서

아이스 아메리카노 (실제 value = 1)

이런식으로 벨류값으로 넘겨주기 떄문에 굳이 내가 직관성을 신경 안 써도 될거 같다! 또 어처피 조인을 하였기 때문에 바보 같은 고민이었던 것 같다......


상품 쿠폰 Service

    public ProductCouponDTO createProductCoupon(ProductCouponDTO productCouponDTO) {

        Product product = productRepository.findById(productCouponDTO.getProductId())
                .orElseThrow(() -> new EntityNotFoundException("상품이 없어요 id 확인해주세요"));

        ProductCoupon productCoupon = new ProductCoupon();
        productCoupon.setCouponName(productCouponDTO.getCouponName());
        productCoupon.setDescription(productCouponDTO.getDescription());
        productCoupon.setIssueDate(productCouponDTO.getIssueDate());
        productCoupon.setExpirationDate(productCouponDTO.getExpirationDate());
        productCoupon.setProduct(product); // Product를 설정

        productCoupon = productCouponRepository.save(productCoupon);

        return new ProductCouponDTO(productCoupon);
    }
public class ProductCouponDTO {
    private String couponName;
    private String description;
    private LocalDate issueDate;
    private LocalDate expirationDate;
    private Long productId;

쿠폰 생성하기 서비스 로직 id를 이용하여 상품을 찾아오고
쿠폰에 상품을 집어넣는 방식
DTO에는 id를 제외한 전부 넣었음

  private ProductCoupon checkCouponId(Long id) {
        return productCouponRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("쿠폰이 없어요 쿠폰 id 확인해주세요"));
    }

쿠폰 id를 체크하는 로직이 중복되서 메소드로 따로 분리

 public ProductCouponDTO readProductCoupon(Long id) {
      ProductCoupon productCoupon = checkCouponId(id);
      return new ProductCouponDTO(productCoupon);
  }

  public void deleteProductCoupon(Long id) {
      ProductCoupon productCoupon = checkCouponId(id);
      productCouponRepository.delete(productCoupon);
  }

간단한 쿠폰 조회와 쿠폰 삭제 메소드 생성


상품 쿠폰 Controller

이번 컨트롤러 부터는 예외처리를 넣어볼 생각이다! 너무 200 ok 만 한거 같아서,,, 일단 추가해보려고 한다.

원래 이렇게 하나씩 전부 쓰려고 했는데 너무 복잡해지는 거 같아 찾아보다가 exceptionHandler 방식을 사용해서 전역으로 처리해볼 예정이다.

exceptionHandler 기에 추후에 한번에 적용하도록 하겠다.

@RestController
@RequestMapping("/coupon")
public class CouponController {
    

    @Autowired
    ProductCouponService productCouponService;

    @PostMapping("/create")
    public ResponseEntity<ProductCouponDTO> createProductCoupon(@RequestBody ProductCouponDTO productCouponDTO) {
        ProductCouponDTO productCoupon = productCouponService.createProductCoupon(productCouponDTO);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(productCoupon); // 응답 바디에 생성된 ProductDTO 포함
    }

    @GetMapping("/read/{id}")
    public ResponseEntity<ProductCouponDTO> readProductCouponDTO(@PathVariable("id") Long id) {
        ProductCouponDTO productCoupon = productCouponService.readProductCoupon(id);
        return ResponseEntity.ok(productCoupon);
    }

    @DeleteMapping("/delete/{id}")
    public ResponseEntity<ProductCouponDTO> deleteProductCoupon(@PathVariable("id") Long id) {
        productCouponService.deleteProductCoupon(id);
        return ResponseEntity.status(HttpStatus.NO_CONTENT)
                .build();
    }
}

컨트롤러 생성


할인 쿠폰

CREATE TABLE discount_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,
    discount_rate DECIMAL(5, 2) NOT NULL,  -- 할인율
    product_id BIGINT,  -- 특정 상품에만 적용되는 경우, NULL 가능
    FOREIGN KEY (product_id) REFERENCES product(id)  -- 상품 테이블과 연관
);

데이터베이스 생성, 특정 상품에만 적용되는 경우가 있으니 상품 id를 받기로 했음.

@Entity
@Table(name = "discount_coupon")
@Getter
@Setter
@NoArgsConstructor
public class DiscountCoupon {

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

    @Column(name = "coupon_name", nullable = false)
    private String couponName; 

    @Column(name = "description")
    private String description; 

    @Column(name = "expiration_date", nullable = false)
    private LocalDate expirationDate; 

    @Column(name = "issue_date", nullable = false)
    private LocalDate issueDate; 

    @Column(name = "discount_rate", nullable = false)
    private java.math.BigDecimal discountRate; 

    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product; 
}

entity 생성 !
BigDecimal은 double이나 float와 달리 정밀도를 제공하며, 정확한 계산이 가능하다고 해서 한번 사용해보았음!

@Service
public class DiscountCouponService {

    @Autowired
    private  DiscountCouponRepository discountRepository;

    @Autowired
    private ProductRepository productRepository;

    public DiscountCouponDTO createDiscountCoupon(DiscountCouponDTO discountCouponDTO) {
        Product product = productRepository.findById(discountCouponDTO.getProductId())
                .orElseThrow(() -> new EntityNotFoundException("상품이 없습니다"));

        DiscountCoupon discountCoupon = new DiscountCoupon();
        discountCoupon.setCouponName(discountCouponDTO.getCouponName());
        discountCoupon.setDescription(discountCouponDTO.getDescription());
        discountCoupon.setExpirationDate(discountCouponDTO.getExpirationDate());
        discountCoupon.setIssueDate(discountCouponDTO.getIssueDate());
        discountCoupon.setDiscountRate(discountCouponDTO.getDiscountRate());
        discountCoupon.setProduct(product);

        discountCoupon = discountRepository.save(discountCoupon);

        return new DiscountCouponDTO(discountCoupon);
    }

    public DiscountCouponDTO readDiscountCoupon(Long id) {
        DiscountCoupon discountCoupon = checkCouponId(id);
        return new DiscountCouponDTO(discountCoupon);
    }

    public void deleteDiscountCoupon(Long id) {
        DiscountCoupon discountCoupon = checkCouponId(id);
        discountRepository.delete(discountCoupon);
    }

    private DiscountCoupon checkCouponId(Long id) {
        return discountRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("할인 쿠폰이 없어요 할인 쿠폰 id 확인해주세요"));
    }

}

상품 쿠폰과 동일하게 할인 쿠폰 CRD 생성
-> 특정 상품에게만 적용하는건? 추후 추가


test 코드

public class DiscountCouponServiceTest {

    @Mock
    private DiscountCouponRepository discountRepository;

    @Mock
    private ProductRepository productRepository;

    @InjectMocks
    private DiscountCouponService discountCouponService;

    private DiscountCoupon discountCoupon;
    private DiscountCouponDTO discountCouponDTO;
    private Product product;

    @BeforeEach
    public void setUp() {
        MockitoAnnotations.openMocks(this);

        product = new Product();
        product.setProductId(1L);
        product.setName("테스트 상품");

        discountCoupon = new DiscountCoupon(1L, "테스트 Sale 쿠폰", "50% off", LocalDate.now().plusDays(30), LocalDate.now(), new BigDecimal("0.50"), product);

        discountCouponDTO = new DiscountCouponDTO();
        discountCouponDTO.setCouponName("테스트 Sale 쿠폰");
        discountCouponDTO.setDescription("50% off");
        discountCouponDTO.setExpirationDate(LocalDate.now().plusDays(30));
        discountCouponDTO.setIssueDate(LocalDate.now());
        discountCouponDTO.setDiscountRate(new BigDecimal("0.50"));
        discountCouponDTO.setProductId(1L);
    }

    @Test
    @DisplayName("할인 쿠폰 생성 테스트")
    public void testCreateDiscountCoupon() {
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        when(discountRepository.save(any(DiscountCoupon.class))).thenReturn(discountCoupon);

        DiscountCouponDTO createdCoupon = discountCouponService.createDiscountCoupon(discountCouponDTO);

        assertNotNull(createdCoupon);
        assertEquals("테스트 Sale 쿠폰", createdCoupon.getCouponName());
        assertEquals("50% off", createdCoupon.getDescription());

        verify(discountRepository, times(1)).save(any(DiscountCoupon.class));
    }

    @Test
    @DisplayName("할인 쿠폰 조회 테스트")
    public void testReadDiscountCoupon() {
        when(discountRepository.findById(1L)).thenReturn(Optional.of(discountCoupon));

        DiscountCouponDTO discountCouponDTO1 = discountCouponService.readDiscountCoupon(1L);

        assertNotNull(discountCouponDTO1);
        assertEquals("테스트 Sale 쿠폰", discountCouponDTO1.getCouponName()); // 쿠폰이름 확인
        verify(discountRepository, times(1)).findById(1L);
    }

    @Test
    @DisplayName("할인 쿠폰 삭제 테스트")
    public void testDeleteDiscountCouponNotFound() {
        when(discountRepository.findById(1L)).thenReturn(Optional.of(discountCoupon));

        discountCouponService.deleteDiscountCoupon(1L);
        
        verify(discountRepository, times(1)).delete(discountCoupon);
    }
}

아직은 너무 생소한... 테스트 코드 차근차근 하다보면 언젠가는 잘 하겠지...?!

@Mock
용도: @Mock 어노테이션은 Mockito를 사용하여 객체의 목(mock) 버전을 생성할 때 사용한다.
목 객체는 테스트하려는 객체와 의존성이 있는 다른 객체들을 가짜로 만들어 실제 구현을 제공하지 않음
(쉽게 테스트계의 bean이라고 생각하자)

@InjectMocks
용도: @InjectMocks 어노테이션은 Mockito가 목 객체를 주입할 대상 객체를 생성할 때 사용
이 어노테이션이 붙은 필드에 대해 Mockito는 해당 클래스의 인스턴스를 생성하고, 클래스의 생성자 또는 setter 메서드를 사용하여 @Mock으로 정의된 객체를 주입한다.

@InjectMocks는 테스트 환경에서 @Autowired와 유사한 역할을 하긴 하지만 테스트 대상 객체의 의존성을 주입하는데 사용

MockitoAnnotations.openMocks(this)
용도: MockitoAnnotations.openMocks(this)는 Mockito의 어노테이션을 초기화하는 메서드
이 메서드는 테스트 클래스 내의 @Mock 및 @InjectMocks 어노테이션이 붙은 필드를 처리하여 실제 목 객체와 의존성 주입을 설정함.
openMocks(this)는 테스트 클래스가 초기화될 때 호출되어야 하며, 보통 @BeforeEach 메서드나 클래스의 생성자에서 호출하게 된다.

times는 verify와 함께 사용되어, 특정 메서드가 호출된 정확한 횟수를 검증하는 데 사용됨
verify : 목(mock) 객체의 메서드가 호출되었는지를 확인하는 데 사용
Optional.of(discountCoupon) 를 사용해서 NPE를 방지하고 명시적인 null 체크!

또한 테스트 코드를 열심히 짜다 보니 김영한 선생님이 테스트 코드는 예외처리를 하는것이 중요하다고 하신게 생각났다.

testReadDiscountCouponNotFound()testDeleteDiscountCouponNotFound() 를 만들어서
잘못된 ID로 조회 및 삭제 시 예외를 발생시키는지 확인해보도록 하자!

    @Test
    @DisplayName("잘못된 할인 쿠폰 조회시 예외 테스트")
    public void testReadDiscountCouponTest() {
        when(discountRepository.findById(1L)).thenReturn(Optional.empty());
        assertThrows(EntityNotFoundException.class, () -> discountCouponService.readDiscountCoupon(1L));
    }

    @Test
    @DisplayName("잘못된 할인 쿠폰 삭제시 예외 테스트")
    public void testDeleteDiscountCouponNotFound() {
        when(discountRepository.findById(1L)).thenReturn(Optional.empty());
            assertThrows(EntityNotFoundException.class, () -> discountCouponService.deleteDiscountCoupon(1L));
        }

뭐 이게 당연한건데 무슨 의미가 있냐 싶을 수 있겠지만도..
개인적으로 테스트 코드를 많이 해보지도 않아서 연습도 되고
테스트 코드를 짜는 과정에서 코드를 더 깊게 이해하게 되어 문제가 나왔을 시 해결 할 수 있는 능력이 상승된다고 기대..?한다!

암튼 오예~!


DiscountCouponController

@RestController
@RequestMapping("/discount-coupons")
public class DiscountCouponController {

    @Autowired
    DiscountCouponService discountCouponService;

    @PostMapping("/create")
    public ResponseEntity<DiscountCouponDTO> createDiscountCoupon(@RequestBody DiscountCouponDTO discountCouponDTO) {
        DiscountCouponDTO discountCoupon = discountCouponService.createDiscountCoupon(discountCouponDTO);
        return ResponseEntity.status(HttpStatus.CREATED).body(discountCoupon);
    }

    @GetMapping("/read/{id}")
    public ResponseEntity<DiscountCouponDTO> readDiscountCoupon(@PathVariable("id") Long id) {
        DiscountCouponDTO discountCouponDTO = discountCouponService.readDiscountCoupon(id);
        return ResponseEntity.ok(discountCouponDTO);
    }

    @DeleteMapping("/delete/{id}")
    public ResponseEntity<Void> deleteDiscountCoupon(@PathVariable("id") Long id) {
        discountCouponService.deleteDiscountCoupon(id);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }

}

마찬가지로 기본적인 CRD에 대한 컨트롤러 생성


다음엔 이제 실제로 쿠폰을 유저에게 발급해주고 사용해보도록 하자!!

profile
자바를자바

0개의 댓글