이번 포스팅에서는 지난번 구현한 장바구니에 담은 메뉴를 주문하는 기능을
구현해려고 합니다. 해당 기능을 구현하기 위해 고려해야할 부분을 생각해보면
첫번째로 회원/비회원을 구분해줘야 합니다. 주소를 등록하고 매장에 들어가 메뉴를
장바구니에 담는것 까지는 회원이든 비회원이든 상관이 없지만 최종 주문의 경우는
회원일경우 포인트를 사용하거나 미리 등록해둔 주소 또는 결제방법을
이용하는등 여러 기능을 사용할수 있어야 합니다
두번째로 주문시에 금액부분에 이상이 없는지를 확인하여야 합니다. 최종 주문버튼을
클릭할시 현재 장바구니에 담긴 메뉴들의 금액과 총 금액이 넘어가게 되는데 이 때
정상적이지 않은 방법으로 이 금액을 임의로 수정하여 넘길 경우 문제가 발생할수 있으므로
서버에서 다시 한번 금액을 확인해줘야 합니다
이제 주문 기능을 구현하기 위해 OrderController를 생성하도록 하겠습니다
@Controller
public class OrderController {
@GetMapping("/order")
public String order(HttpSession session, Model model) {
CartListDto cartListDto = (CartListDto) session.getAttribute("cartList");
model.addAttribute("cartList", cartListDto);
String orderNum = CreateOrderNum.createOrderNum();
model.addAttribute("orderNum", orderNum);
return "order/order";
}
}
우리는 장바구니에 메뉴를 담을때 session에 저장했었기 때문에 주문페이지에서
장바구니에 있는 메뉴들을 가져올수 있도록 session에 있는 정보를
model에 심어줍니다. orderNum는 주문번호로 현재 날자 + 랜덤숫자의 조합으로
되어 있습니다. 이 주문번호를 생성하기 위해 utils패키지안에 CreateOrderNum클래스를
추가해주도록 하겠습니다
public class CreateOrderNum {
public static String createOrderNum() {
Calendar cal = Calendar.getInstance();
int y = cal.get(Calendar.YEAR);
int m = cal.get(Calendar.MONTH) + 1;
int d = cal.get(Calendar.DATE);
StringBuilder sb = new StringBuilder();
sb.append(y).append(m).append(d);
for (int i = 0; i < 10; i++) {
int random = (int) (Math.random() * 10);
sb.append(random);
}
return sb.toString();
}
}
StringBuilder는 String을 합쳐주는 기능을 합니다
String은 변경 불가능한 문자열을 생성하지만 StringBuilder는 변경 가능한 문자열을
만들어 주기 때문에 여러 문자열을 합칠때 많이 사용합니다
주문번호를 왜 최종 주문시에 생성하지 않고 주문페이지에서 미리 생성하냐면 단순
포인트만 사용해서 결제를 할 경우 포인트 사용 -> 포인트 확인 -> 포인트 차감 -> 주문완료
와 동시에 주문번호를 생성해도 상관이 없습니다. 하지만 외부서비스를 이용하여
결제 진행시 주문 완료보다 결제완료가 먼저 진행되기 때문에 이 외부서비스(결제)에서는
주문번호를 알수 없습니다. 이 경우 만약 고객이 주문취소를 한다고 했을때 외부서비스는
주문번호를 저장해두지 않았기 때문에 완료된 결제목록중 어느것을 취소해야하는지
알수가 없습니다. 그렇기 때문에 주문페이지 접근시 미리 주문번호를 생성해줘야 합니다
이제 주문페이지를 구현하기 위해 views , css , js 폴더안에 order폴더를 생성하고
jsp, css , js를 추가해줘야 합니다 코드가 너무 길어 링크로 첨부하도록 하겠습니다
이제 주문페이지에 접근하면 비회원으로 주문할지 로그인을 할지가 나타납니다
비회원의 경우 저장된 회원정보가 없으므로 모든 항목을 직접 작성해야 하며
포인트를 사용할수 없습니다.
회원의 경우 이미 저장된 회원정보가 있으므로 가져다 사용하면 되는데 이때 서버에서
따로 Order페이지에 뿌려줄 필요가 없습니다
지난번 스프링 시큐리티 로그인을 구현할때 header.jsp에 시큐리티 태그 라이브러리를
사용하여 principal이라는 이름으로 회원의 정보를 참조할수 있도록 설정하였습니다
header.jsp는 모든 페이지의 레이아웃으로 사용되므로 어디에서든 현재 로그인된
사용자의 정보를 사용할수 있습니다
비회원의 경우 로그인 버튼을 누르면 로그인페이지로 넘어가게 되는데 이때 중요한게
하나 있습니다. 현재 사용자는 주문페이지에 접근하였고 이때 로그인 페이지로 넘어가서
로그인에 성공할경우 주문페이지가 아닌 제일 처음화면으로 이동됩니다
따라서 로그인에 성공하면 그 직전 페이지로 다시 돌아갈수 있도록 설정해줘야합니다
AuthController로 가서 다음의 코드를 수정해주도록 합니다
//기존
@GetMapping("/auth/signin")
public String signin() {
return "auth/signin";
}
//수정
@GetMapping("/auth/signin")
public String signin(HttpServletRequest request, HttpSession session) {
String referer = (String) request.getHeader("referer");
session.setAttribute("referer", referer);
return "auth/signin";
}
로그인 페이지로 이동하기 전에 현재 페이지의 Header정보를 세션에 저장해줍니다
config패키지안에 SecurityConfig를 수정해줍니다
//추가
@Autowired
private LoginSuccess loginSuccess;
//기존
...
.loginPage("/auth/signin")
.loginProcessingUrl("/auth/signin")
.defaultSuccessUrl("/home")
.failureUrl("/auth/failed");
//수정
...
.loginPage("/auth/signin")
.loginProcessingUrl("/auth/signin")
.successHandler(loginSuccess)
.failureUrl("/auth/failed");
기존에는 로그인 성공시 자동으로 home페이지로 이동되도록 설정하였습니다
하지만 우리는 로그인시에 바로 직전 페이지로 돌아가도록 해주기 위해
loginSuccess라는 이름으로 successHandler를 하나 추가하도록 할겁니다
config-auth 패키지안에 LoginSuccess 클래스를 하나 추가해줍시다
@Component
public class LoginSuccess implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
HttpSession session = request.getSession();
String referer = (String) session.getAttribute("referer");
if(referer != null) {
String domain = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort();
String uri = referer.replace(domain, "");
System.out.println("uri = " + uri);
if(uri.equals("/order")) {
response.sendRedirect(uri);
return;
}
}
response.sendRedirect("/home");
}
}
AuthenticationSuccessHandler를 상속받아 만약 주문페이지에서 로그인을 하였다면
세션에 저장된 정보가 존재하므로 다시 주문페이지로 이동시키고 만약 존재하지 않는다면
home페이지로 이동시킵니다
이제 주문페이지에 구현은 끝났으므로 주문 기능을 구현해야 합니다
사용자가 주문페이지에서 필요한 정보를 입력한후 주문하기 버튼을 클릭했을시
서버에서 주문정보를 받기 위한 Dto를 하나 생성해 줍시다
@Data
public class OrderInfoDto {
private String orderNum;
private long storeId;
private long userId;
private Date orderDate;
private String deleveryStatus;
private int deleveryAddress1;
private String deleveryAddress2;
private String deleveryAddress3;
private String payMethod;
private int totalPrice;
private int usedPoint;
private String phone;
private String request;
}
비동기 요청을 통해 해당 데이터를 처리하기 위해 api패키지안에 api컨트롤러와 service와 dao, mapper를 추가해주도록 하겠습니다
@RestController
public class OrderApiController {
@Autowired
OrderService orderService;
@PostMapping("/api/order/payment-cash")
public ResponseEntity<?> payment(HttpSession session, OrderInfoDto orderInfoDto, long totalPrice, @AuthenticationPrincipal CustomUserDetails principal) throws IOException {
CartListDto cartListDto = (CartListDto) session.getAttribute("cartList");
long orderPriceCheck = orderService.orderPriceCheck(cartListDto);
System.out.println("계산금액 = " + totalPrice + " 실제 계산해야할 금액 = " + orderPriceCheck );
if(orderPriceCheck == totalPrice) {
orderService.order(cartListDto, orderInfoDto, principal, session);
session.removeAttribute("cartList");
}
return ResponseEntity.ok().body("주문금액 일치");
}
}
@Service
public class OrderService {
@Autowired
OrderMapper orderMapper;
@Transactional
public long orderPriceCheck(CartListDto cartListDto) {
System.out.println("cartDetail = " + cartListDto);
List<CartDto> cart = cartListDto.getCartDto();
List<Integer> foodPriceList = orderMapper.foodPriceList(cart);
List<Integer> optionPriceList = orderMapper.optionPriceList(cart);
int deliveryTip = orderMapper.getDeliveryTip(cartListDto.getStoreId());
System.out.println("foodPriceList = " + foodPriceList);
System.out.println("optionPriceList = " + optionPriceList);
int sum = 0;
for (int i = 0; i < cart.size(); i++) {
int foodPrice = foodPriceList.get(i);
int amount = cart.get(i).getAmount();
int optionPrice = optionPriceList.get(i);
sum += (foodPrice + optionPrice) * amount;
}
return sum + deliveryTip;
}
}
@Mapper
public interface OrderMapper {
// 메뉴 총합가격 계산시 배달팁 가져오기
public int getDeliveryTip(long storeId);
// 메뉴 총합가격 계산시 음식가격
public List<Integer> foodPriceList(List<CartDto> cartList);
// 메뉴 총합가격 계산시 음식 추가 옵션가격
public List<Integer> optionPriceList(List<CartDto> cartList);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.han.delivery.dao.OrderMapper">
<select id="getDeliveryTip" resultType="int">
SELECT DELiVERY_TIP FROM BM_STORE WHERE ID = #{storeId }
</select>
<select id="foodPriceList" resultType="int">
<foreach collection="list" item="item" separator="union all" >
select sum(food_price) sum from bm_food where id = #{item.foodId}
</foreach>
</select>
<select id="optionPriceList" resultType="int">
<foreach collection="list" item="item" separator="union all" >
<if test="item.optionId == null">
select 0 from dual
</if>
<if test="item.optionId != null">
select sum(option_price) sum from bm_food_option where id in
<foreach collection="item.optionId" item="i" open="(" close=")" separator="," >
#{i}
</foreach>
</if>
</foreach>
</select>
</mapper>
위의 코드는 포스팅 제일 처음에 말했던 결제금액에 이상이 없는지 확인하는 부분입니다
모든 주문은 장바구니를 통해 이루어지고 이 장바구니는 사용자의 session에 저장되어
있습니다 최종 주문 금액은 배달팁 + ( 메뉴 + 옵션 ) * 수량 이므로
session에 저장된 메뉴와 옵션으로 DB를 조회하여 각각의 가격을 모두 더하여줍니다
세션에 저장된 메뉴와 옵션은 한가지가 아닐수 있으므로 List이며 이때 List에서
아이템을 하나하나 꺼내어 DB를 참조할경우 수 많은 select문이 나가게 되며 이는
엄청나게 비효율적입니다 이를 위해 mybatis는 List를 parameter로 사용할수 있는
foreach을 제공합니다 foreach와 union을 통해 장바구니에 저장된 모든 메뉴에
대한 가격을 얻을수 있고 추가옵션의 경우 모든 메뉴에 항상 존재하는것이 아니므로
if문과 2번의 foreach문을 통해 장바구니에 존재하는 모든 추가옵션의 가격을 구할수
있습니다.
이때 결과 또한 List로 나오기 때문에 for문을 이용하여 각각의 메뉴에 대해
(메뉴가격+추가옵션가격) * 수량을 모두 더하고 마지막으로 배달팁을 더하여
이 가격이 OrderInfoDto의 totalPrice와 일치할 경우에 최종적으로 주문을 처리합니다
이제 최종적으로 주문을 처리하기 위해 주문의 상세내역을 저장할 Dto를 하나 추가하겠습니다
@Data
@AllArgsConstructor
public class OrderDetailDto {
private String orderNum;
private String foodInfoJSON;
}
이 Dto를 통해 우리는 DB에 주문메뉴를 JSON타입으로 저장할겁니다
JSON타입으로 저장하는 이유는 한 메뉴에 대한 추가옵션의 경우 여러개가 존재할수
있으므로 DB테이블에 여러개의 칼럼을 추가하는 방법은 좋지 않습니다
이제 Object를 JSON타입으로 쉽게 변환하기 위해 pom.xml에
GSON라이브러리를 추가해주겠습니다
<!-- gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
@Transactional
public String order(CartListDto cartListDto, OrderInfoDto orderInfoDto, CustomUserDetails principal, HttpSession session) {
Gson gson = new Gson();
System.out.println("info = " + orderInfoDto);
int total = cartListDto.getCartTotal();
orderInfoDto.setStoreId(cartListDto.getStoreId());
orderInfoDto.setTotalPrice(total);
long userId = 0;
if (principal != null) {
userId = principal.getId();
orderInfoDto.setUserId(userId);
}
List<CartDto> cartList = cartListDto.getCartDto();
OrderDetailDto[] orderDetailDto = new OrderDetailDto[cartList.size()];
for(int i=0;i<orderDetailDto.length;i++) {
String cartJSON = gson.toJson(cartList.get(i));
orderDetailDto[i] = new OrderDetailDto(orderInfoDto.getOrderNum(), cartJSON);
}
orderMapper.order(orderInfoDto);
Map<String, Object> orderDetailMap = new HashMap<>();
orderDetailMap.put("userId", userId);
orderDetailMap.put("detail", orderDetailDto);
orderMapper.orderDetail(orderDetailMap);
return null;
}
주문의 경우 회원/비회원으로 나뉘기 떄문에 DB에 테이블을 따로 생성해줄겁니다
따라서 현재 사용자가 회원인지 비회원인지를 확인하여야합니다
또한 하나의 주문에는 여러개의 메뉴와 추가옵션이 존재할수 있으므로 orderDetailDto를
배열로 선언해줘야 합니다 마지막으로 mybatis를 이용하여 테이블에 해당 정보를
저장해야하는데 이때 우리는 사용자가 회원인지 비회원인지 판단하기 위한 userId와
메뉴정보가 JSON의 형태로 저장된 배열인 orderDetailDto가 모두 필요합니다
따라서 우리는 Map에 필요한 데이터를 넣어준후 DB에 저장시켜 줘야 합니다
이제 해당 정보를 저장하기 위해 Oracle에 테이블을 추가해주도록 합시다
CREATE TABLE DL_ORDER_USER (
ORDER_NUM NUMBER PRIMARY KEY,
STORE_ID NUMBER NOT NULL,
USER_ID NUMBER NOT NULL,
ORDER_DATE TIMESTAMP DEFAULT SYSDATE,
PAY_METHOD VARCHAR2(30),
DELIVERY_STATUS VARCHAR2(50) DEFAULT '주문접수 대기 중',
PHONE VARCHAR2(20) NOT NULL,
DELIVERY_ADDRESS1 NUMBER NOT NULL,
DELIVERY_ADDRESS2 VARCHAR2(200) NOT NULL,
DELIVERY_ADDRESS3 VARCHAR2(200),
TOTAL_PRICE NUMBER NOT NULL,
USED_POINT NUMBER DEFAULT 0,
REQUEST VARCHAR2(2000),
IMP_UID VARCHAR2(30) -- 아임포트 결제번호
);
CREATE TABLE DL_ORDER_DETAIL_USER (
ORDER_NUM NUMBER,
FOOD_INFO VARCHAR2(2000)
);
ALTER TABLE DL_ORDER_DETAIL_USER
ADD CONSTRAINT ORDER_DETAIL_USER
FOREIGN KEY (ORDER_NUM)
REFERENCES DL_ORDER_USER(ORDER_NUM)
on DELEte cascade;
-- 비회원 테이블
-- 회원 비회원 union all 하기위해 user_id 컬럼 추가
CREATE TABLE DL_ORDER_NON_USER (
ORDER_NUM NUMBER PRIMARY KEY,
STORE_ID NUMBER NOT NULL,
USER_ID NUMBER NOT NULL,
ORDER_DATE TIMESTAMP DEFAULT SYSDATE,
PAY_METHOD VARCHAR2(30),
DELIVERY_STATUS VARCHAR2(50) DEFAULT '주문접수 대기 중',
PHONE VARCHAR2(20) NOT NULL,
DELIVERY_ADDRESS1 NUMBER NOT NULL,
DELIVERY_ADDRESS2 VARCHAR2(200) NOT NULL,
DELIVERY_ADDRESS3 VARCHAR2(200),
TOTAL_PRICE NUMBER NOT NULL,
USED_POINT NUMBER DEFAULT 0,
REQUEST VARCHAR2(2000),
IMP_UID VARCHAR2(30) -- 아임포트 결제번호
);
CREATE TABLE DL_ORDER_DETAIL_NON_USER (
ORDER_NUM NUMBER,
FOOD_INFO VARCHAR2(2000)
);
ALTER TABLE DL_ORDER_DETAIL_NON_USER
ADD CONSTRAINT ORDER_DETAIL_NON_USER
FOREIGN KEY (ORDER_NUM)
REFERENCES DL_ORDER_NON_USER(ORDER_NUM)
on DELEte cascade;
// 주문 정보 입력
public void order(OrderInfoDto orderInfoDto);
// 주문 상세정보 입력
public void orderDetail(Map<String, Object> map);
<insert id="order">
<if test="userId == 0">
INSERT INTO DL_ORDER_NON_USER (
ORDER_NUM
,STORE_ID
,USER_ID
,PAY_METHOD
,PHONE
,DELIVERY_ADDRESS1
,DELIVERY_ADDRESS2
,DELIVERY_ADDRESS3
,TOTAL_PRICE
,USED_POINT
,REQUEST
) VALUES (
${orderNum }
,#{storeId }
,#{userId }
,#{payMethod }
,#{phone }
,#{DeliveryAddress1 }
,#{DeliveryAddress2 }
,#{DeliveryAddress3 }
,#{totalPrice }
,#{usedPoint }
,#{request }
)
</if>
<if test="userId != 0">
INSERT INTO DL_ORDER_USER (
ORDER_NUM
,STORE_ID
,USER_ID
,PAY_METHOD
,PHONE
,DELIVERY_ADDRESS1
,DELIVERY_ADDRESS2
,DELIVERY_ADDRESS3
,TOTAL_PRICE
,USED_POINT
,REQUEST
) VALUES (
${orderNum }
,#{storeId }
,#{userId }
,#{payMethod }
,#{phone }
,#{DeliveryAddress1 }
,#{DeliveryAddress2 }
,#{DeliveryAddress3 }
,#{totalPrice }
,#{usedPoint }
,#{request }
)
</if>
</insert>
<update id="orderDetail" parameterType="java.util.HashMap">
<if test= "userId == 0">
<foreach collection="detail" item="item" separator=" " open="INSERT ALL" close="SELECT * FROM DUAL">
INTO DL_ORDER_DETAIL_NON_USER (
ORDER_NUM
,FOOD_INFO
) VALUES (
#{item.orderNum}
,#{item.foodInfoJSON}
)
</foreach>
</if>
<if test= "userId != 0">
<foreach collection="detail" item="item" separator=" " open="INSERT ALL" close="SELECT * FROM DUAL">
INTO DL_ORDER_DETAIL_USER (
ORDER_NUM
,FOOD_INFO
) VALUES (
#{item.orderNum}
,#{item.foodInfoJSON}
)
</foreach>
</if>
</update>
insert order의 경우 OrderInfoDto와 1:1 대응이기에 단순히 사용자로부터
입력 받은 정보를 테이블에 삽입하면 됩니다 하지만 orderDetail의 경우 파라미터를
map으로 받았고 이 map안에는 object 배열이 존재합니다 이 배열에서 아이템을
하나씩 꺼내서 테이블에 저장할 경우 여러번의 쿼리가 나가게 됩니다 이러한 문제를
해결하기 위해 foreach과 INSERT ALL을 사용하면 한번의 쿼리로 배열의 모든 데이터를
DB에 저장할수 있습니다 이때 Oracle사용시에는 실제로는 insert를 수행하지만
반드시 update를 사용하여야 에러가 발생하지 않습니다
이제 기본적인 주문 기능은 거의 구현이 끝났습니다. 하지만 아직 몇가지 추가해야할
부분이 있는데 로그인된 사용자면 주문완료시 포인트가 적립되어야 하며
적립한 포인트를 사용할 경우 DB에서 해당 포인트를 차감시켜야 합니다
이부분은 다음 포스팅에서 구현해보도록 하겠습니다