구매자가 이용할 구매페이지를 만들고 구매요청과 취소요청을 트랜잭션을 이용해여 처리해보자
우선 확인할 것들
( 일반적인 코드 작성 순서 - DB - 쿼리 - 서비스 - 컨트롤러 )
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
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)
);
( 데이터 입력 쿼리는 생략 )
고객과 상품의 PK가 연결된 새로운 테이블 생성 ( N : N 관계 )
( 추가적으로 @PathVariable
는 PK 만 가능함 )
( PK를 연결하면 데이터의 무결성 유지 )
중요 쿼리만 보자
<!-- 유저 인증 -->
<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>
<!-- 상품 수정 -->
<update id="updateById">
update product_tb set
name = #{name},
price = #{price},
qty = #{qty}
where id = #{id}
</update>
<!-- 구매 기록 추가 -->
<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>
@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 )
구매목록을 만들기 위해서 데이터를 담을 모델이 필요
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 는 사용못함 ( 예약어 ) / 대문자도 사용하지 않음