[Spring] 구매, 취소 처리 - 트랜잭션, 테이블 관계

merci·2023년 1월 22일
1

Spring

목록 보기
7/21

구매자가 이용할 구매페이지를 만들고 구매요청과 취소요청을 트랜잭션을 이용해여 처리해보자

우선 확인할 것들

  • build.gradle 라이브러리
  • application.yml
  • h2 DB 에 사용할 sql설정 파일
  • MyBatis 에 사용할 mapper - *.xml 파일
  • webapp 폴더
  • 기능에 따른 폴더 구분



( 일반적인 코드 작성 순서 - DB - 쿼리 - 서비스 - 컨트롤러 )

기본 설정

  • application.yml
server:
  port: 8080
  servlet:
    encoding:
      charset: utf-8
      force: true
    session:
      timeout: 30
spring:
  datasource:
    url: jdbc:h2:mem:test;MODE=MySQL
    driver-class-name: org.h2.Driver
    username: sa
    password: 
  mvc:
    view:
      prefix: /WEB-INF/view/
      suffix: .jsp    
  sql:
    init:
      schema-locations:
      - classpath:db/table.sql
      data-locations:
      - classpath:db/data.sql
  h2:
    console:
      enabled: true
  output:
    ansi:
      enabled: always
mybatis:
  mapper-locations:
  - classpath:mapper/**.xml
  configuration:
    map-underscore-to-camel-case: true
  • 3개의 테이블 사용 user_tb product_tb purchase_tb
create table user_tb(
    id int auto_increment primary key,
    username varchar not null unique,
    password varchar not null,
    email varchar not null, 
    created_at timestamp
);

create table product_tb(
    id int auto_increment primary key,
    name varchar not null unique,
    price int not null,
    qty int not null, 
    created_at timestamp
);

create table purchase_tb(
    id int auto_increment primary key,
    user_id int,
    product_id int,
    count int,
    created_at timestamp,
    CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user_tb (id),
    CONSTRAINT fk_product FOREIGN KEY (product_id) REFERENCES product_tb (id)
);

( 데이터 입력 쿼리는 생략 )

  • user_tb
  • product_tb

고객과 상품의 PK가 연결된 새로운 테이블 생성 ( N : N 관계 )

  • purchase_tb


테이블 관계

1 : 1

  • 테이블 레코드가 서로 1 : 1 로 매칭될 경우 - 서로가 FK ( Foreign Key ) 를 가질 수가 있다 ( 누가 가지든 상관없다 )

1 : N

  • 하나의 레코드가 다른 테이블의 여러 레코드와 매칭된 경우
  • 하나의 레코드를 부모테이블이라 하면 자식테이블은 부모의 PK 를 FK 로 가질 수가 있다.
    ( N 테이블이 FK 를 가질 수가 있다 )
  • 데이터를 삭제할때는 N 테이블부터 삭제해야 한다 ( 1 부터 삭제하면 부모를 참조중인 다른 테이블은 ? )

N : N

  • 여러 레코드가 다른 테이블의 여러 레코드와 매칭 ( 지금같은 경우 )
  • 조인 테이블을 만들어서 두 테이블의 PK를 FK로 가진다 ( user_id, product_id )

( 추가적으로 @PathVariable 는 PK 만 가능함 )
( PK를 연결하면 데이터의 무결성 유지 )




쿼리 작성

  • 어떤 쿼리가 필요할지 모르면 기본적으로 CRUD 는 만들자 ( 예를들어 - 회원가입, 로그인, 회원탈퇴, 회원수정 )

중요 쿼리만 보자

  • mapper/user.xml
<!-- 유저 인증 -->
<select id="findByUsernameAndPassword" resultType="shop.mtcoding.buyer.model.User">
          select * from user_tb where username = #{username} and password = #{password}
</select>

<!-- 회원 가입 -->
<insert id="insert">
      insert into user_tb
        (username, password, email, created_at) 
        values (#{username}, #{password}, #{email}, now())
</insert>
  • mapper/product.xml
<!-- 상품 수정 -->
<update id="updateById">
      update product_tb set 
        name = #{name},
        price = #{price},
        qty = #{qty}
      where id = #{id}
</update>
  • mapper/purchase.xml
<!-- 구매 기록 추가 -->
<insert id="insert">
      insert into purchase_tb
        (user_id, product_id, count, created_at) 
        values (#{userId}, #{productId}, #{count}, now())
</insert>

<!-- 구매 기록 수정 -->
<update id="updateByUserId">
      update purchase_tb set 
        user_id = #{userId},
        product_id = #{productId},
        count = #{count}
      where id = #{id}
</update>
  • Repository 생략
  • 테이블 수에 맞게 엔티티 생성




서비스 클래스

  • @Service 어노테이션을 붙여 서비스객체로 IoC 컨테이너에 생성
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import shop.mtcoding.buyer.model.Product;
import shop.mtcoding.buyer.model.ProductRepository;
import shop.mtcoding.buyer.model.Purchase;
import shop.mtcoding.buyer.model.PurchaseRepository;

/*  @Controller , @RestController , @Mapper , @Service , @Component --> IoC 에 생성 */
@Service
public class PurchaseService {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private PurchaseRepository purchaseRepository;

    @Transactional
    public int 구매하기(int principalId, int productId, int count) {
        // 1. 상품 존재 확인
        Product product = productRepository.findById(productId);
        if (product == null)
            return -1;

        // 2. 논리적으로 입력 가능한 수량인지 확인
        if (product.getQty() <= count)
            return -1;

        // 3. 재고 수량 변경
        int result2 = productRepository.updateById(
                product.getId(),
                product.getName(),
                product.getPrice(),
                product.getQty() - count);
        if (result2 != 1)
            return -1;

        // 4. 구매 이력 남기기 ( 구매 테이블은 마지막에 )
        int result = purchaseRepository.insert(principalId, productId, count);
        if (result != 1)
            return -1;       

        // 커밋
        return 1;
    }

    @Transactional
    public int 구매취소하기(int purchaseId) {

        // 존재하는 구매 레코드인지 검증
        Purchase purchase = purchaseRepository.findById(purchaseId);
        if (purchase == null)
            return -1;

        // 존재하는 상품인지 검증
        Product product = productRepository.findById(purchase.getProductId());
        if (product == null)
            return -1;

        // 상품테이블 수정
        int result = productRepository.updateById(
                product.getId(),
                product.getName(),
                product.getPrice(),
                product.getQty() + purchase.getCount());
        if (result != 1)
            return -1;

        // 구매 테이블 레코드 삭제
        int result2 = purchaseRepository.deleteById(purchaseId);
        if (result2 != 1)
            return -1;

        return 1;
    }
}

이러한 과정을 비즈니스 로직 이라고 한다
비즈니스 로직에서 트랜잭션이 필요 ( 원자성을 지켜야함 )

  • 비즈니스 로직의 순서가 중요하다

  • 먼저 파라미터로 입력받은 데이터( 변수 )는 입력가능한 데이터인지 검증부터 해야한다

  • 논리적으로 가능한지 검증한다

  • 조인된 테이블은 ( 구매 테이블 ) 마지막에 처리한다

  • 하나의 테이블을 수정하는 메소드는 서비스 레이어로 가져와서 처리하지 않아도 된다




트랜잭션

  • 처리의 최소 단위, 예를들면 송금할때 나의 계좌에서 돈이 빠져나가고, 상대의 계좌에 돈이 추가되고, 송금에 대한 history를 기록하는 3가지의 과정을 말함

  • 트랜잭션은 ACID 원칙을 지켜야하는데
    A ( 원자성 ) -> 트랜잭션의 모든 처리가 완료되지 않으면 처음상태로 롤백 하게 되고, 모든 처리가 성공적으로 완료되면 커밋을 한다.

  • 여러 트랜잭션을 처리할때는 서비스 레이어에서 처리한다

  • 컨트롤러와 리포지토리 사이에서 트랜잭션 처리가 필요하면 서비스 레이어를 거쳐야 한다

  • 트랜잭션 중에는 동기화가 이루어진다 -> 컨트롤러에서 트랜잭션을 걸면 안된다 ( 대기 시간 낭비 )

  • 따라서 최대한 DB와 가까운 레이어에서 트랜잭션을 걸어야 한다

  • 서비스 레이어의 존재 이유는 트랜잭션을 줄이기 위함이다

  • 스프링에서는 @Transactional 어노테이션을 붙이면 런타임 익셉션 발생시 강제로 롤백을 해준다
    ( setAutoCommit(false) + commit() // rollback() )




컨트롤러

import java.util.List;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import shop.mtcoding.buyer.dto.PurchaseAllDto;
import shop.mtcoding.buyer.model.PurchaseRepository;
import shop.mtcoding.buyer.model.User;
import shop.mtcoding.buyer.service.PurchaseService;

@Controller
public class PurchaseController {

    @Autowired
    private PurchaseRepository purchaseRepository;

    @Autowired
    private HttpSession session;

    @Autowired // IoC 컨테이너 -> DI
    private PurchaseService purchaseService;
    
    @GetMapping("/purchase") // nav - 구매목록
    public String purchase(Model model) {
        User principal = (User) session.getAttribute("principal");
        if (principal == null) {
            return "redirect:/notfound";
        }
        // 필요한 데이터만 테이블에서 꺼내온다 ( PurchaseAllDto )
        List<PurchaseAllDto> purchaseList 
        	= purchaseRepository.findbyUserId(principal.getId());
        model.addAttribute("purchaseList", purchaseList);
        return "purchase/list";
    }

    @PostMapping("/purchase/insert") // 상품페이지 - 구매하기 버튼
    public String purchaselist(int productId, int count) {
        // 1. 세션 체크 ( 유저 확인 ) - 컨트롤러의 책임
        User principal = (User) session.getAttribute("principal");
        if (principal == null) {
            return "redirect:/notfound";
        }

        // 2.서비스 호출
        int result = purchaseService.구매하기(principal.getId(), productId, count);
        if (result == -1) {
            return "redirect:/notfound";
        }
        return "redirect:/";
    }

    @PostMapping("/purchase/delete") // 구매목록 - 구매취소 버튼
    public String purchaseDelete(int purchaseId) {
        User principal = (User) session.getAttribute("principal");
        if (principal == null) {
            return "redirect:/notfound";
        }

        int result = purchaseService.구매취소하기(purchaseId);
        if (result == -1)
            return "redirect:/notfound";

        return "redirect:/";
    }
}
  • 코드 작성은 필터링 방식으로 작성해야한다 - if문으로만 구성
    ( 무한 IF ELSE 에 빠지면 끔찍한 가독성 )

  • 구매목록을 위한 쿼리가 필요


구매목록 쿼리

	<select id="findbyUserId" resultType="shop.mtcoding.buyer.dto.PurchaseAllDto">
         select pu.id, u.username, pr.name, pu.count, pu.created_at
            from (select * from purchase_tb
            where user_id = #{id} ) pu
            inner join user_tb u
            on pu.user_id = u.id
            inner join product_tb pr
            on pr.id = pu.product_id;     
    </select>
  • 또 다른 쿼리로는
  <select id="findbyUserId" resultType="shop.mtcoding.buyer.dto.PurchaseAllDto">
      select id,
         ( select username from user_tb where id = a.user_id ) username,
         ( select name from product_tb where id = a.product_id ) name,
         count,
         created_at
         from purchase_tb  a
         where user_id = #{id}
   </select>

  • 어떤 테이블을 드라이빙테이블 ( 메인 테이블 ) 로 만드냐에 따라 연산시간을 줄일 수가 있다
    ( 처음부터 조건을 줄여서 조인이나 서브쿼리 이용 )

  • 보통 메인 테이블이 FK 키를 가지면 가장 빠르다

  • 회원정보가 아닌 모든 레코드를 보는 경우 outer 조인을 사용 ( 게시글 조회 )

  • 데이터 정렬시 번호를 rownum 으로 붙이는데 내가 원하는 대로 번호를 붙이고 싶다면 ( 가격순, 조회순등 ) 먼저 인라인뷰로 정렬하고 번호를 붙여야 한다
    ( 쿼리 프로젝션은 from 부터 하니까 내부적으로 인라인뷰를 만들고 그다음 번호 붙임 )
    select * , rownum from ( SELECT * FROM PRODUCT_TB order by price desc )

구매목록을 만들기 위해서 데이터를 담을 모델이 필요

  • 모델을 설계 하면 수정하기 힘들다 - DTO 를 만들어서 뷰로 전달 ( 자유롭게 수정 가능 )
  • DTO는 초기에 설계 하지 않는다

DTO ( Data Transfer Object ) - 데이터 전송 객체

import java.sql.Timestamp;
import lombok.Getter;
import lombok.Setter;
import shop.mtcoding.buyer.util.DateUtil;

@Setter
@Getter
public class PurchaseAllDto {
    private int id;
    private String username; // 유저이름
    private String name; // 상품이름
    private int count;
    private Timestamp createdAt;

    public String getCreatedAtToString() {
        return DateUtil.format(createdAt);
    }
}
  • 유틸 클래스로 DateUtil 작성
  • 먼저 유닛 테스트로 검증하고 가져오자
@Test
public void parse_test() {
        // given
        Timestamp timestamp = Timestamp.valueOf(LocalDateTime.now());
        // 2023-02-05 14:16:08.9553591
        LocalDateTime nowTime = timestamp.toLocalDateTime();
        //2023-02-05T14:16:08.955359100
        String nowStr = nowTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        // 2023-02-05
}

날짜 양식 변경

import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class DateUtil {
    public static String format(Timestamp stamp) {
        return stamp.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }
}

결과는





참고로 매핑시 /error 는 사용못함 ( 예약어 ) / 대문자도 사용하지 않음

profile
작은것부터

0개의 댓글