본문 바로가기

카테고리 없음

Spring 미니 프로젝트 [RN & Spring] - 포인트 -> 쿠폰 구매

지난 시간에 이어서 걸음 수 -> 포인트 -> 구매 로직을 개발한다.

 

포인트-> 쿠폰 구매

우선 쿠폰 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);
    }
}

 

현재 내 쿠폰 컨트롤러는 단순히 회원 개인에 대한 쿠폰 정보들을 나열한다.

 

아키텍쳐 고민

  1. 쿠폰 상점 API를 따로 만들어야 하나?
    1. 전체 쿠폰 조회 API를 따로 둔다. 모든 회원들은 상점을 볼 수 있다.
  2. 포인트를 차감하는 로직
    1. 구매 버튼을 누르면 userPoint에 대한 검사를 진행한다. 돈이 충분하다면 userPoint를 차감하고 PointHistoryRepository에 '쿠폰 구매 차감' 을 남긴다.
  3. Member 엔티티에 쿠폰 정보를 추가한다?
    1. 컬렉션이나 리스트로 때려넣어도 쿠폰에 대한 정보가 많다
    2. MemberCoupon 이라는 엔티티를 하나 더 만들어서 관리한다!!
      (지금 있는 /api/{memberId}/coupons API 같은 것)
      1. 회원과 쿠폰은 N : N 다대다 관계이다.
        한 명의 회원이 여러 개의 쿠폰을 살 수 있고, 하나의 쿠폰은 여러 회원이 살 수 있다.
      2. 둘을 연결해서 일대다, 다대일 관계로 매핑하는 중간 엔티티가 필요하다.
        회원 <-> MemberCoupon <-> 쿠폰 상점
         1       :    N    /      N   :      1  이렇게 분리할 수 있다!

엔티티

쿠폰 상점을 역할을 하는 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를 변경할 계획이다.

추후에는 로그인과 관련된 정보를 공부해서 적용할 계획이다!

파이팅!