
두구두구두구 머지해와서 상품들이 생겼다!
본격적으로 개발해보고자 한다!
이제 로그인을 구현했으니 쿠폰을 적용시켜보려고 한다.
쿠폰은 상품쿠폰(ex : 아이스티 한잔) , 가격 쿠폰( ex : 20%할인) 등으로 나누어서 하려고 한다.
User <- userCoupon -> ProductCoupon, DiscountCoupon
일단 상품, 및 매장이 완전히 개발되고 있지 않아서 먼저 기본적인 툴을 만들어보고 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;
상품 영양정보를 넣은건 좋은 코드인거 같다 !
@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)
이런식으로 벨류값으로 넘겨주기 떄문에 굳이 내가 직관성을 신경 안 써도 될거 같다! 또 어처피 조인을 하였기 때문에 바보 같은 고민이었던 것 같다......
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);
}
간단한 쿠폰 조회와 쿠폰 삭제 메소드 생성
이번 컨트롤러 부터는 예외처리를 넣어볼 생각이다! 너무 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 생성
-> 특정 상품에게만 적용하는건? 추후 추가
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));
}
뭐 이게 당연한건데 무슨 의미가 있냐 싶을 수 있겠지만도..
개인적으로 테스트 코드를 많이 해보지도 않아서 연습도 되고
테스트 코드를 짜는 과정에서 코드를 더 깊게 이해하게 되어 문제가 나왔을 시 해결 할 수 있는 능력이 상승된다고 기대..?한다!
암튼 오예~!
@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에 대한 컨트롤러 생성
다음엔 이제 실제로 쿠폰을 유저에게 발급해주고 사용해보도록 하자!!