지난 시간에 이어서 걸음 수 -> 포인트 -> 구매 로직을 개발한다.
포인트-> 쿠폰 구매
우선 쿠폰 API에서는 Get메서드를 활용해서 회원별로 사용이 가능한 쿠폰들을 리스트로 조회할 수 있게 했다.
그렇다면 쿠폰 사용을 눌렀을 때 쿠폰을 얻음과 동시에 포인트가 줄어드는 로직이 있어야 한다.
지금 상황은 멤버 Id에 따른 쿠폰들이기 때문에 쿠폰 API를 따로 설정하고 Member가 가진 쿠폰을 담는 컬럼이 필요하다.
package NetZero.controller;
import NetZero.domain.Member;
import NetZero.dto.CouponResponse;
import NetZero.service.CouponService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
public class CouponController {
private final CouponService couponService;
@GetMapping("/{memberId}/coupons")
public List<CouponResponse> getMyCoupons(@PathVariable("memberId") Member member){
return couponService.getMyCoupons(member);
}
}
현재 내 쿠폰 컨트롤러는 단순히 회원 개인에 대한 쿠폰 정보들을 나열한다.
아키텍쳐 고민
- 쿠폰 상점 API를 따로 만들어야 하나?
- 전체 쿠폰 조회 API를 따로 둔다. 모든 회원들은 상점을 볼 수 있다.
- 포인트를 차감하는 로직
- 구매 버튼을 누르면 userPoint에 대한 검사를 진행한다. 돈이 충분하다면 userPoint를 차감하고 PointHistoryRepository에 '쿠폰 구매 차감' 을 남긴다.
- Member 엔티티에 쿠폰 정보를 추가한다?
- 컬렉션이나 리스트로 때려넣어도 쿠폰에 대한 정보가 많다
- MemberCoupon 이라는 엔티티를 하나 더 만들어서 관리한다!!
(지금 있는 /api/{memberId}/coupons API 같은 것)- 회원과 쿠폰은 N : N 다대다 관계이다.
한 명의 회원이 여러 개의 쿠폰을 살 수 있고, 하나의 쿠폰은 여러 회원이 살 수 있다. - 둘을 연결해서 일대다, 다대일 관계로 매핑하는 중간 엔티티가 필요하다.
회원 <-> MemberCoupon <-> 쿠폰 상점
1 : N / N : 1 이렇게 분리할 수 있다!
- 회원과 쿠폰은 N : N 다대다 관계이다.
엔티티
쿠폰 상점을 역할을 하는 Coupon 이라는 엔티티를 추가로 만들고
원래 존재하던 CouponInfo 엔티티를 다듬어서 쿠폰 구매 영수증 엔티티를 만든다.
package NetZero.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@Getter
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long CouponId;
private String name;
private int price;
private String imageUrl;
public Coupon(String name, int price){
this.name = name;
this.price = price;
}
}
package NetZero.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Entity
@Getter
@NoArgsConstructor
public class MemberCoupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long MemberCouponId; //영수증 아이디
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "coupon_id")
private Coupon coupon;
private String couponName;
private LocalDate validDate;
private boolean isUsed;
public MemberCoupon(Member member, Coupon coupon, String couponName, LocalDate validDate) {
this.member = member;
this.coupon = coupon;
this.couponName = couponName;
this.validDate = validDate;
this.isUsed = false;
}
}
쿠폰 상점과 쿠폰 영수증의 관계는 일대다 이기 때문에
그리고 회원과 쿠폰 영수증의 관계도 일대다 이다.
따라서 @ManyToOne 을 사용하고, @JoinColumn 을 적어서 맵핑관계를 구현한다.
서비스
- 유저가 쿠폰 구매 요청을 보낸다.
- 서버가 유저의 포인트를 보고 해당 쿠폰을 구매할 수 있는지 확인한다.
- 구매가 가능하다면, 포인트를 차감하고 포인트 이력을 기록한다.
- 쿠폰 이름과 함께 내 쿠폰함에 넣어준다.
package NetZero.service;
import NetZero.domain.Coupon;
import NetZero.domain.Member;
import NetZero.domain.MemberCoupon;
import NetZero.domain.PointHistory;
import NetZero.dto.CouponResponse;
import NetZero.exception.BusinessException;
import NetZero.exception.ErrorCode;
import NetZero.repository.CouponRepository;
import NetZero.repository.MemberCouponRepository;
import NetZero.repository.MemberRepository;
import NetZero.repository.PointHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponRepository couponRepository; //쿠폰 상점
private final MemberRepository memberRepository;
private final MemberCouponRepository memberCouponRepository; //쿠폰 영수증
private final PointHistoryRepository pointHistoryRepository;
@Transactional(readOnly = true)
public List<CouponResponse> getMyCoupons(Member member) {
return memberCouponRepository.findByMemberAndIsUsedFalse(member)
.stream()
.map(CouponResponse::new)
.collect(Collectors.toList());
}
@Transactional
public void purchaseCoupon(String memberId, long couponId){
Member member = memberRepository.findById(memberId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
Coupon coupon = couponRepository.findById(couponId).orElseThrow(() -> new BusinessException(ErrorCode.COUPON_NOT_FOUND));
member.usePoint(coupon.getPrice()); //member내부에서 포인트가 충분한 지 검사
pointHistoryRepository.save(new PointHistory(member, -coupon.getPrice(), coupon.getName() + "구매"));
String couponName = UUID.randomUUID().toString().substring(0, 8);
LocalDate validDate = LocalDate.now().plusMonths(1);
MemberCoupon memberCoupon = new MemberCoupon(member, coupon, couponName, validDate);
memberCouponRepository.save(memberCoupon);
}
}
기존에 제작했던 쿠폰 리스트를 띄우는 메서드는 MemberCouponRepository에서 사용할 수 있도록 남겨두었다.
public interface MemberCouponRepository extends JpaRepository<MemberCoupon, Long> {
List<MemberCoupon> findByMemberAndIsUsedFalse(Member member);
}
-------------------------------
public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
레포지토리를 하나 더 만들고 기존에 있던 것은 수정했다.
Repository<A, ID> 무조건 자신의 이름에 맞는 A 엔티티를 넣는다! 엔티티와 PK 타입을 작성한다!
HTTP 통신
구매 로직은 어떻게 통신해서 작동할까?
- GET
- 우리가 흔히 조회할 때 사용한다.
- 서버의 데이터는 변하지 않는다.
- POST
- 모든 작업이 다 된다고 배웠다.
- 데이터를 변경시킨다.
- 유저 포인트를 차감하고, 내 쿠폰함으로 이동시키고, 포인트 이력을 기록한다.
결론: GET으로 만들면, 새로 고침할 때 쿠폰이 계속 결제되는 문제 등이 발생할 수 있다.
따라서 POST로 구현해야 한다!
컨트롤러
package NetZero.controller;
import NetZero.domain.Member;
import NetZero.domain.MemberCoupon;
import NetZero.dto.CouponRequest;
import NetZero.dto.MyCouponResponse;
import NetZero.service.CouponService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/coupons")
@RequiredArgsConstructor
public class CouponController {
private final CouponService couponService;
@GetMapping ("/{memberId}/coupons")
public List<MyCouponResponse> getMyCoupons(@PathVariable("memberId") Member member){
return couponService.getMyCoupons(member.getId());
}
@PostMapping ("/{couponId}/purchase")
public MyCouponResponse purchaseCoupon(@PathVariable("couponId") Long couponId, @RequestBody CouponRequest couponRequest){
MemberCoupon memberCoupon = couponService.purchaseCoupon(couponRequest.getMemberId(), couponId);
return MyCouponResponse.from(memberCoupon);
}
}
쿠폰을 구매하는 서비스 로직을 넣어서 작성한다.
DTO
- 내 쿠폰함
- 내 쿠폰함 화면을 띄울 때 필요한 것이 무엇인지 확인한다.
- 쿠폰 이름과, 결제 바코드, 유효 기간, 사용 여부 등이 있다.
- 엔티티를 받아서 DTO로 변환시키는 메서드도 추가한다.
package NetZero.dto;
import NetZero.domain.Coupon;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Getter
@NoArgsConstructor
public class CouponRequest {
private String memberId;
}
먼저, 쿠폰 구매 요청이 올 때 사용할 DTO를 만든다. 구매 후 멤버 쿠폰함에 들어가야 하므로 멤버 아이디를 가져온다.
package NetZero.dto;
import NetZero.domain.MemberCoupon;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Getter
@AllArgsConstructor
public class MyCouponResponse {
private Long memberCouponId;
private String couponName;
private String barcode;
private LocalDate validDate;
private boolean isUsed;
public static MyCouponResponse from(MemberCoupon memberCoupon){
return new MyCouponResponse(
memberCoupon.getMemberCouponId(),
memberCoupon.getCoupon().getName(),
memberCoupon.getBarcode(),
memberCoupon.getValidDate(),
memberCoupon.isUsed()
);
}
}
쿠폰을 구매했을 때 내 쿠폰함으로 응답을 준다. 그러기 위해서 쿠폰 아이디와 이름, 바코드, 유효기간, 사용 여부 등을 담는다.
응답으로 바로 주기 위해서 정적 메소드를 활용한다.
@Transactional(readOnly = true)
public List<MyCouponResponse> getMyCoupons(String memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
List<MemberCoupon> myCoupons = memberCouponRepository.findByMemberAndIsUsedFalse(member);
return myCoupons.stream()
.map(MyCouponResponse::from)//DTO에 있는 팩토리 메서드 사용, static이라 바로 호출
.toList();
}
쿠폰함을 불러오는 서비스 로직에서 바로 JSON 형태로 줄 수 있다!
간단한 테스트


멤버를 넣어두고 상점에 쿠폰도 채운다.
그리고 스웨거에서 쿠폰 구매 요청을 날려서 로직이 잘 작동하는지 확인했다.


의도했던 바와 같이 응답으로 쿠폰함에 들어갈 수 있는 정보를 반환한다.
이제 내 쿠폰함과 포인트 이력, 유저 포인트를 확인해본다.



모든 것이 계산과 일치한다.
로직이 어느 정도 완성되어서 다음에는 Junit을 활용한 테스트를 진행하고, DB를 변경할 계획이다.
추후에는 로그인과 관련된 정보를 공부해서 적용할 계획이다!
파이팅!