상품 목록보기 (기능 1)
응답 Json
{
"success": true,
"response": [
{
"id": 1,
"productName": "기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전",
"description": "",
"image": "/images/1.jpg",
"price": 1000
},
{
"id": 2,
"productName":"[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율", "description": "",
"image": "/images/2.jpg",
"price": 2000
}
],
"error": null
}
응답 DTO
package com.example.kakao.product;
import lombok.Getter;
import lombok.Setter;
public class ProductResponse {
@Getter
@Setter
public static class FindAllDTO {
private int id;
private String productName;
private String description;
private String image;
private int price;
//깊은 복사
public FindAllDTO(Product product) {
this.id = product.getId();
this.productName = product.getProductName();
this.description = product.getDescription();
this.image = product.getImage();
this.price = product.getPrice();
}
}
}
json 바탕으로 응답 dto 생성
서비스
@RequiredArgsConstructor //DI
@Service //IoC 등록
public class ProductService {
private final ProductJPARepository productRepository;
public List<ProductResponse.FindAllDTO> findAll(int page) {
// 1. 페이지 객체 만들기
Pageable pageable = PageRequest.of(page,9);
// 2. DB 조회하기
Page<Product> pageContent = productRepository.findAll(pageable);
// 3. DTO 만들기
// 페이지 객체의 content는 List이다.
// List를 stream()으로 변환 -> 자바 오브젝트의 타입이 없어진다. (강물에 흩뿌린다)
// map으로 순회하면서 값을 변한한다. (가공)
// 가공된 데이터를 다시 자바 오브젝트로 변환한다.
List<ProductResponse.FindAllDTO> responseDTOs = pageContent.getContent().stream()
.map(product -> new ProductResponse.FindAllDTO(product))
.collect(Collectors.toList());
return responseDTOs;
}
}
Repository에서 Product를 가져와 DTO 생성
Controller
@RequiredArgsConstructor
@RestController
public class ProductRestController {
private final ProductService productService;
// (기능1) 전체 상품 목록 조회 (페이징 9개씩)
// /products
@GetMapping("/products")
public ResponseEntity<?> findAll(@RequestParam(value = "page", defaultValue =
"0") Integer page) {
List<ProductResponse.FindAllDTO> responseDTOs =
productService.findAll(page);
return ResponseEntity.ok(ApiUtils.success(responseDTOs));
}
}
서비스를 호출하고 응답 결과 전송
상품 상세보기 (기능 2)
응답 Json
{
"success": true,
"response": {
"id": 1,
"productName": "기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전",
"description": "",
"image": "/images/1.jpg",
"price": 1000,
"starCount": 5,
"options": [
{
"id": 1,
"optionName": "01. 슬라이딩 지퍼백 크리스마스에디션 4종",
"price": 10000
},
{
"id": 2,
"optionName": "02. 슬라이딩 지퍼백 플라워에디션 5종",
"price": 10900
}
] },
"error": null
}
1. 하나씩 조회해서 가져오기
응답 DTO
public class ProductResponse {
@Getter
@Setter
public static class FindByIdDTO {
private int id;
private String productName;
private String description;
private String image;
private int price;
private int starCount; // 0~5
private List<OptionDTO> options;
public FindByIdDTO(Product product, List<Option> optionList) {
this.id = product.getId();
this.productName = product.getProductName();
this.description = product.getDescription();
this.image = product.getImage();
this.price = product.getPrice();
this.starCount = 5; // 임시로 추가해둠 (요구사항에는 없음)
this.options = optionList.stream().map(OptionDTO::new).collect(Collectors.toList());
}
@Getter
@Setter
public class OptionDTO {
private int id;
private String optionName;
private int price;
public OptionDTO(Option option) {
this.id = option.getId();
this.optionName = option.getOptionName();
this.price = option.getPrice();
}
}
}
서비스
@Transactional(readOnly = true) //변경감지 X (더티체킹X)
@RequiredArgsConstructor //DI
@Service //IoC 등록
public class ProductService {
private final ProductJPARepository productRepository;
private final OptionJPARepository optionRepository;
public ProductResponse.FindByIdDTO findById(int id) {
Product productPS = productRepository.findById(id).orElseThrow(
() -> new Exception404("해당 상품을 찾을 수 없습니다 : "+id)
);
List<Option> optionListPS = optionRepository.findByProductId(productPS.getId());
return new ProductResponse.FindByIdDTO(productPS, optionListPS);
}
}
Repository
public interface OptionJPARepository extends JpaRepository<Option, Integer> {
List<Option> findByProductId(@Param("productId") int productId);
}
네임 쿼리
Controller
@RequiredArgsConstructor
@RestController
public class ProductRestController {
private final ProductService productService;
// (기능2) 개별 상품 상세 조회
// /products/{id}
@GetMapping("/products/{id}")
public ResponseEntity<?> findById(@PathVariable int id) {
ProductResponse.FindByIdDTO responseDTO = productService.findById(id);
ApiUtils.ApiResult<?> apiResult = ApiUtils.success(responseDTO);
return ResponseEntity.ok(apiResult);
}
}
결과
select 쿼리 2번 수행 - 두번째 사진 보면 option을 product와 join하고 있는데, option만 조회해도되므로 수정하자.
네임쿼리는 fk로 join하여 수행하므로 추천X - 직접 쿼리 작성하기
레포지토리
public interface OptionJPARepository extends JpaRepository<Option, Integer> {
@Query("select o from Option o where o.product.id = :productId") //직접 쿼리 작성
List<Option> findByProductId(@Param("productId") int productId);
}
직접 쿼리 작성 @Query
실행 결과
쿼리 2번 수행했으나 option 조회시 필요하지 않는 join을 수행하지 않아 더 좋다.
2. Join 해서 가져오기 - 추천 ⭐️
응답 DTO
public class ProductResponse {
@Getter
@Setter
public static class FindByIdDTOv2 {
private int id;
private String productName;
private String description;
private String image;
private int price;
private int starCount; // 0~5
private List<OptionDTO> options;
public FindByIdDTOv2(List<Option> optionList) {
this.id = optionList.get(0).getProduct().getId();
this.productName = optionList.get(0).getProduct().getProductName();
this.description = optionList.get(0).getProduct().getDescription();
this.image = optionList.get(0).getProduct().getImage();
this.price = optionList.get(0).getProduct().getPrice();
this.starCount = 5;
this.options = optionList.stream().map(OptionDTO::new).collect(Collectors.toList());
}
@Getter
@Setter
public class OptionDTO {
private int id;
private String optionName;
private int price;
public OptionDTO(Option option) {
this.id = option.getId();
this.optionName = option.getOptionName();
this.price = option.getPrice();
}
}
}
}
하나의 product에 대해 여러개의 option 결과가 존재
서비스
@Transactional(readOnly = true) //변경감지 X (더티체킹X)
@RequiredArgsConstructor //DI
@Service //IoC 등록
public class ProductService {
private final ProductJPARepository productRepository;
private final OptionJPARepository optionRepository;
public ProductResponse.FindByIdDTOv2 findByIdv2(int id) {
List<Option> optionListPS = optionRepository.findByProductIdJoinProduct(id); //join fetch
if(optionListPS.size() == 0){
throw new Exception404("해당 상품을 찾을 수 없습니다 : "+id);
}
return new ProductResponse.FindByIdDTOv2(optionListPS);
}
}
레포지토리 - join fetch 수행 쿼리
public interface OptionJPARepository extends JpaRepository<Option, Integer> {
List<Option> findByProductId(@Param("productId") int productId);
// @Query(value = "select * from option o inner join product p on o.product_id = p.id where o.product_id = :productId", nativeQuery = true) //native 쿼리
@Query("select o from Option o join fetch o.product where o.product.id = :productId")
List<Option> findByProductIdJoinProduct(@Param("productId") int productId);
}
위는 join 수행하지않는 네임쿼리, 아래는 join 수행하는 쿼리
join 수행 쿼리는 native query로 적어도 좋지만 아래처럼 적는 것이 더 편하다(표현 방식만 다르고 쿼리 수행 동작은 같음)
Controller
@RequiredArgsConstructor
@RestController
public class ProductRestController {
private final ProductService productService;
// (기능2) 개별 상품 상세 조회 v2
// /products/{id}/v2
@GetMapping("/products/{id}/v2")
public ResponseEntity<?> findByIdv2(@PathVariable int id) {
ProductResponse.FindByIdDTOv2 responseDTO = productService.findByIdv2(id);
ApiUtils.ApiResult<?> apiResult = ApiUtils.success(responseDTO);
return ResponseEntity.ok(apiResult);
}
}
결과
inner join 후에 option과 product를 한번에 가져오는 join fetch - 쿼리 1번 (더 좋은 쿼리)
기능 3,4,5는 User쪽이므로 넘어감
장바구니 담기(기능 6)
Cart 서비스
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CartService {
private final OptionJPARepository optionJPARepository;
private final CartJPARepository cartJPARepository;
@Transactional //트랜잭션 시작
public void addCartList(List<CartRequest.SaveDTO> requestDTOs, User sessionUser) {
// 1. 동일한 옵션이 들어오면 예외처리
// [ { optionId:1, quantity:5 }, { optionId:1, quantity:10 } ]
// 2. cartJPARepository.findByOptionIdAndUserId() 조회 -> 존재하면 장바구니에 수량을 추가하는 업데이트를 해야함. (더티체킹하기)
// Cart {cartId:1, optionId:1, quantity:3, userId:1} -> DTO {optionId:1, quantity:5}
// 3. [2번이 아니라면] 유저의 장바구니에 담기
for (CartRequest.SaveDTO requestDTO : requestDTOs) {
int optionId = requestDTO.getOptionId();
int quantity = requestDTO.getQuantity();
Option optionPS = optionJPARepository.findById(optionId)
.orElseThrow(() -> new Exception404("해당 옵션을 찾을 수 없습니다 : " + optionId));
int price = optionPS.getPrice() * quantity;
Cart cart = Cart.builder().user(sessionUser).option(optionPS).quantity(quantity).price(price).build();
cartJPARepository.save(cart);
}
} //변경감지, 더티체킹, flush, 트랜잭션 종료
}
Controller
@RequiredArgsConstructor
@RestController
public class CartRestController {
private final CartService cartService;
/**
* [
* {
* "optionId":1,
* "quantity":5
* },
* {
* "optionId":2,
* "quantity":5
* }
* ]
*/
// (기능6) 장바구니 담기 POST
// /carts/add
@PostMapping("/carts/add")
public ResponseEntity<?> addCartList(@RequestBody @Valid List<CartRequest.SaveDTO> requestDTOs, Errors errors, @AuthenticationPrincipal CustomUserDetails userDetails) {
cartService.addCartList(requestDTOs, userDetails.getUser());
return ResponseEntity.ok(ApiUtils.success(null));
}
}
서비스 호출 후 응답
테스트 데이터 생성
배포 전 테스트시 데이터가 필요할때 서버 띄울때마다 dummy data를 만들어주는 것이 필요하다.
환경설정 파일 만들기
application-local.yml
server:
servlet:
encoding:
charset: utf-8
force: true
port: 8080
spring:
datasource:
url: jdbc:h2:mem:test;MODE=MySQL
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
open-in-view: false
logging:
level:
'[com.example.kakao]': DEBUG
'[org.hibernate.type]': TRACE
file:
path: ./images/
local 설정
application-prod.yml
server:
servlet:
encoding:
charset: utf-8
force: true
port: 8080
spring:
datasource:
url: ${DATABASE_URL}?allowPublicKeyRetrieval=true&useSSL=false
driver-class-name: org.mariadb.jdbc.Driver
username: root
password: root
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
open-in-view: false
logging:
level:
'[com.example.kakao]': INFO
'[org.hibernate.type]': TRACE
file:
path: ./images/
prod 설정
application.yml
spring:
profiles:
active:
- local
현재 Profile을 local로 설정 - 배포할때는 prod로 변경
Application
@SpringBootApplication
public class KakaoApplication {
public static void main(String[] args) {
SpringApplication.run(KakaoApplication.class, args);
}
@Profile("local") //local일때
@Bean
CommandLineRunner localServerStart(UserJPARepository userJPARepository, ProductJPARepository productJPARepository, OptionJPARepository optionJPARepository, PasswordEncoder passwordEncoder){
return args -> { //행위가 리턴
userJPARepository.saveAll(Arrays.asList(newUser("ssarmango", passwordEncoder)));
productJPARepository.saveAll(Arrays.asList(
newProduct("기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전", 1, 1000),
newProduct("[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율", 2, 2000),
newProduct("삼성전자 JBL JR310 외 어린이용/성인용 헤드셋 3종!", 3, 30000),
newProduct("바른 누룽지맛 발효효소 2박스 역가수치보장 / 외 7종", 4, 4000),
newProduct("[더주] 컷팅말랑장족, 숏다리 100g/300g 외 주전부리 모음 /중독성 최고/마른안주", 5, 5000),
newProduct("굳지않는 앙금절편 1,050g 2팩 외 우리쌀떡 모음전", 6, 15900),
newProduct("eoe 이너딜리티 30포, 오렌지맛 고 식이섬유 보충제", 7, 26800),
newProduct("제나벨 PDRN 크림 2개. 피부보습/진정 케어", 8, 25900),
newProduct("플레이스테이션 VR2 호라이즌 번들. 생생한 몰입감", 9, 797000),
newProduct("통영 홍 가리비 2kg, 2세트 구매시 1kg 추가증정", 10, 8900),
newProduct("아삭한 궁채 장아찌 1kg 외 인기 반찬 모음전", 11, 6900),
newProduct("깨끗한나라 순수소프트 30롤 2팩. 무형광, 도톰 3겹", 12, 28900),
newProduct("생활공작소 초미세모 칫솔 12입 2개+가글 증정", 13, 9900),
newProduct("경북 영천 샤인머스켓 가정용 1kg 2수 내외", 14, 9900),
newProduct("[LIVE][5%쿠폰] 홈카페 Y3.3 캡슐머신 베이직 세트", 15, 148000)
));
optionJPARepository.saveAll(Arrays.asList(
newOption(Product.builder().id(1).build(), "01. 슬라이딩 지퍼백 크리스마스에디션 4종", 10000),
newOption(Product.builder().id(1).build(), "02. 슬라이딩 지퍼백 플라워에디션 5종", 10900),
newOption(Product.builder().id(1).build(), "고무장갑 베이지 S(소형) 6팩", 9900),
newOption(Product.builder().id(1).build(), "뽑아쓰는 키친타올 130매 12팩", 16900),
newOption(Product.builder().id(1).build(), "2겹 식빵수세미 6매", 8900),
newOption(Product.builder().id(2).build(), "22년산 햇단밤 700g(한정판매)", 9900),
newOption(Product.builder().id(2).build(), "22년산 햇단밤 1kg(한정판매)", 14500),
newOption(Product.builder().id(2).build(), "밤깎기+다회용 구이판 세트", 5500),
newOption(Product.builder().id(3).build(), "JR310 (유선 전용) - 블루", 29900),
newOption(Product.builder().id(3).build(), "JR310BT (무선 전용) - 레드", 49900),
newOption(Product.builder().id(3).build(), "JR310BT (무선 전용) - 그린", 49900),
newOption(Product.builder().id(3).build(), "JR310BT (무선 전용) - 블루", 49900),
newOption(Product.builder().id(3).build(), "T510BT (무선 전용) - 블랙", 52900),
newOption(Product.builder().id(3).build(), "T510BT (무선 전용) - 화이트", 52900),
newOption(Product.builder().id(4).build(), "선택01_바른곡물효소 누룽지맛 2박스", 17900), //15
newOption(Product.builder().id(4).build(), "선택02_바른곡물효소누룽지맛 6박스", 50000),
newOption(Product.builder().id(4).build(), "선택03_바른곡물효소3박스+유산균효소3박스", 50000),
newOption(Product.builder().id(4).build(), "선택04_바른곡물효소3박스+19종유산균3박스", 50000),
newOption(Product.builder().id(5).build(), "01. 말랑컷팅장족 100g", 4900),
newOption(Product.builder().id(5).build(), "02. 말랑컷팅장족 300g", 12800),
newOption(Product.builder().id(5).build(), "03. 눌린장족 100g", 4900),
newOption(Product.builder().id(6).build(), "굳지않는 쑥 앙금 절편 1050g", 15900),
newOption(Product.builder().id(6).build(), "굳지않는 흑미 앙금 절편 1050g", 15900),
newOption(Product.builder().id(6).build(), "굳지않는 흰 가래떡 1050g", 15900),
newOption(Product.builder().id(7).build(), "이너딜리티 1박스", 26800), //25
newOption(Product.builder().id(7).build(), "이너딜리티 2박스+사은품 2종", 49800),
newOption(Product.builder().id(8).build(), "제나벨 PDRN 자생크림 1+1", 25900),
newOption(Product.builder().id(9).build(), "플레이스테이션 VR2 호라이즌 번들", 839000),
newOption(Product.builder().id(9).build(), "플레이스테이션 VR2", 797000),
newOption(Product.builder().id(10).build(), "홍가리비2kg(50미이내)", 8900), //30
newOption(Product.builder().id(11).build(), "궁채 절임 1kg", 6900),
newOption(Product.builder().id(11).build(), "양념 깻잎 1kg", 8900),
newOption(Product.builder().id(11).build(), "된장 깻잎 1kg", 8900),
newOption(Product.builder().id(11).build(), "간장 깻잎 1kg", 7900),
newOption(Product.builder().id(11).build(), "고추 무침 1kg", 8900),
newOption(Product.builder().id(11).build(), "파래 무침 1kg", 9900),
newOption(Product.builder().id(12).build(), "01_순수소프트 27m 30롤 2팩", 28900),
newOption(Product.builder().id(12).build(), "02_벚꽃 프리미엄 27m 30롤 2팩", 32900),
newOption(Product.builder().id(13).build(), "(증정) 초미세모 칫솔 12개 x 2개", 11900),
newOption(Product.builder().id(13).build(), "(증정) 잇몸케어 치약 100G 3개 x 2개", 16900),
newOption(Product.builder().id(13).build(), "(증정) 구취케어 치약 100G 3개 x 2개", 16900),
newOption(Product.builder().id(13).build(), "(증정)화이트케어 치약 100G 3개 x 2개", 19900),
newOption(Product.builder().id(13).build(), "(증정) 어린이 칫솔 12EA", 9900),
newOption(Product.builder().id(14).build(), "[가정용] 샤인머스켓 1kg 2수내외", 9900),
newOption(Product.builder().id(14).build(), "[특품] 샤인머스켓 1kg 1-2수", 12900), //45
newOption(Product.builder().id(14).build(), "[특품] 샤인머스켓 2kg 2-3수", 23900),
newOption(Product.builder().id(15).build(), "화이트", 148000),
newOption(Product.builder().id(15).build(), "블랙", 148000)
));
};
}
private User newUser(String username, PasswordEncoder passwordEncoder){
return User.builder()
.email(username+"@nate.com")
.password(passwordEncoder.encode("meta1234!"))
.username(username)
.roles(username.equals("admin") ? "ROLE_ADMIN" : "ROLE_USER")
.build();
}
private Product newProduct(String productName, int imageNumber, int price){
return Product.builder()
.productName(productName)
.description("")
.image("/images/"+imageNumber+".jpg")
.price(price)
.build();
}
private Option newOption(Product product, String optionName, int price){
return Option.builder()
.product(product)
.optionName(optionName)
.price(price)
.build();
}
}
Profile이 local일때 CommandRunner를 이용하여 테스트에 필요한 dummy data를 만드는 행위 수행
CommandLineRunner 인터페이스
@FunctionalInterface
public interface CommandLineRunner {
/**
* Callback used to run the bean.
* @param args incoming main method arguments
* @throws Exception on error
*/
void run(String... args) throws Exception;
}
서버 구동 시점에 지정된 초기화 작업을 수행할 수 있다.
1. Application 안에 작성하기
@SpringBootApplication
public class KakaoApplication {
public static void main(String[] args) {
SpringApplication.run(KakaoApplication.class, args);
}
@Profile("local") //local일때
@Bean
CommandLineRunner localServerStart(UserJPARepository userJPARepository, ProductJPARepository productJPARepository, OptionJPARepository optionJPARepository, PasswordEncoder passwordEncoder){
return args -> { //행위가 리턴
userJPARepository.saveAll(Arrays.asList(newUser("ssarmango", passwordEncoder)));
productJPARepository.saveAll(Arrays.asList(
newProduct("기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전", 1, 1000),
newProduct("[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율", 2, 2000),
newProduct("삼성전자 JBL JR310 외 어린이용/성인용 헤드셋 3종!", 3, 30000)
));
};
}
}
위 코드는 아래와 같다.
@SpringBootApplication
public class KakaoApplication {
public static void main(String[] args) {
SpringApplication.run(KakaoApplication.class, args);
}
@Profile("local") //local일때
@Bean
CommandLineRunner localServerStart(UserJPARepository userJPARepository, ProductJPARepository productJPARepository, OptionJPARepository optionJPARepository, PasswordEncoder passwordEncoder){
return new CommandLineRunner(){
@Override
public void run(String... args) throws Exception{ //run 구현
//메서드 수행
userJPARepository.saveAll(Arrays.asList(newUser("ssarmango", passwordEncoder)));
productJPARepository.saveAll(Arrays.asList(
newProduct("기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전", 1, 1000),
newProduct("[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율", 2, 2000),
newProduct("삼성전자 JBL JR310 외 어린이용/성인용 헤드셋 3종!", 3, 30000)
));
}
};
}
}
내부적으로는 CommandLineRunner가 인터페이스이므로 run 메서드를 구현하여 작동한다.
2. 클래스로 생성하기
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component //@Bean이 아닌 @Component
public class LocalServerCommandLineRunner implements CommandLineRunner {
private final UserJPARepository userJPARepository;
private final ProductJPARepository productJPARepository;
private final OptionJPARepository optionJPARepository;
private final PasswordEncoder passwordEncoder;
public LocalServerCommandLineRunner(
UserJPARepository userJPARepository,
ProductJPARepository productJPARepository,
OptionJPARepository optionJPARepository,
PasswordEncoder passwordEncoder) {
this.userJPARepository = userJPARepository;
this.productJPARepository = productJPARepository;
this.optionJPARepository = optionJPARepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public void run(String... args) throws Exception { //run 직접 구현
// 메서드 수행
userJPARepository.saveAll(Arrays.asList(newUser("ssarmango", passwordEncoder)));
productJPARepository.saveAll(Arrays.asList(
newProduct("기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전", 1, 1000),
newProduct("[황금약단밤 골드]2022년산 햇밤 칼집밤700g외/군밤용/생율", 2, 2000),
newProduct("삼성전자 JBL JR310 외 어린이용/성인용 헤드셋 3종!", 3, 30000)
));
}
private User newUser(String username, PasswordEncoder passwordEncoder) {
// 구현
}
private Product newProduct(String name, int optionId, int quantity) {
// 구현
}
}
클래스로 분리시 @Bean이 아닌 @Component를 사용한다.
@Bean은 @Configuration이 등록된 클래스의 메서드에서 주로 선언되고,
@Component는 클래스 위에서 선언된다.
또한 @Bean은 제어할 수 없는 외부 라이브러리 등록시 사용하고, ex)DataSourceProperties, ObjectMapper
@Component는 개발자가 직접 제어가능한 Class 등록시 사용한다.
장바구니 조회(기능 7)
응답 JSON
{
"success": true,
"response": {
"products": [
{
"id": 1,
"productName": "기본에 슬라이딩 지퍼백 크리스마스/플라워에디션 에디션 외 주방용품 특가전",
"carts": [
{
"id": 33,
"option": {
"id": 1,
"optionName": "01. 슬라이딩 지퍼백 크리스마스에디션 4종",
"price": 10000
},
"quantity": 5,
"price": 50000
},
{
"id": 34,
"option": {
"id": 2,
"optionName": "02. 슬라이딩 지퍼백 플라워에디션 5종",
"price": 10900
},
"quantity": 5,
"price": 54500
}
]
}
],
"totalPrice": 434300
},
"error": null
}
DTO
public class CartResponse {
@Getter
@Setter
public static class FindAllDTO {
private List<ProductDTO> products;
private int totalPrice;
public FindAllDTO(List<Cart> cartList) {
this.products = cartList.stream()
// 중복되는 상품 걸러내기
.map(cart -> cart.getOption().getProduct()).distinct()
//DTO 생성
.map(product -> new ProductDTO(product, cartList))
//List로 반환
.collect(Collectors.toList());
this.totalPrice = cartList.stream().mapToInt(cart -> cart.getOption().getPrice() * cart.getQuantity()).sum();
}
@Getter
@Setter
public class ProductDTO {
private int id;
private String productName;
private List<CartDTO> carts;
public ProductDTO(Product product, List<Cart> cartList) {
this.id = product.getId();
this.productName = product.getProductName();
// 현재 상품과 동일한 장바구니 내역만 담기
this.carts = cartList.stream()
.filter(cart -> cart.getOption().getProduct().getId() == product.getId())
.map(CartDTO::new)
.collect(Collectors.toList());
}
@Getter
@Setter
public class CartDTO {
private int id;
private OptionDTO option;
private int quantity;
private int price;
public CartDTO(Cart cart) {
this.id = cart.getId();
this.option = new OptionDTO(cart.getOption());
this.quantity = cart.getQuantity();
this.price = cart.getOption().getPrice() * cart.getQuantity();
}
@Getter
@Setter
public class OptionDTO {
private int id;
private String optionName;
private int price;
public OptionDTO(Option option) {
this.id = option.getId();
this.optionName = option.getOptionName();
this.price = option.getPrice();
}
}
}
}
}
}
Service
public CartResponse.FindAllDTO findAll(User user) {
List<Cart> cartList = cartJPARepository.findByUserIdOrderByOptionIdAsc(user.getId());
// Cart에 담긴 옵션이 3개이면, 2개는 바나나 상품, 1개는 딸기 상품이면 Product는 2개인 것이다.
return new CartResponse.FindAllDTO(cartList);
}
Repository
@Query("select c from Cart c where c.user.id = :userId order by c.option.id asc")
List<Cart> findByUserIdOrderByOptionIdAsc(int userId);
Controller
@GetMapping("/carts")
public ResponseEntity<?> findAll(@AuthenticationPrincipal CustomUserDetails userDetails) {
CartResponse.FindAllDTO responseDTO = cartListService.findAll(userDetails.getUser());
ApiUtils.ApiResult<?> apiResult = ApiUtils.success(responseDTO);
return ResponseEntity.ok(apiResult);
}
결과
많은 쿼리 --> fetch join 이용해서 쿼리 수정하기
@Query("select c from Cart c join fetch c.option o join fetch o.product p where c.user.id = :userId order by c.option.id asc")
List<Cart> findByUserIdOrderByOptionIdAsc(int userId);
결과
한번에 쿼리 수행 -> 좋은 쿼리
(자주 사용되는 쿼리라면 성능 좋게만들기)
DB에서 쿼리 수행한다면 아래와 같음
추천X DTO - 프론트에게 친절X
public class CartResponse {
@Getter
@Setter
public static class FindAllDTOv2 {
private List<ProductDTO> products;
public FindAllDTOv2(List<Cart> cartList) {
this.products = cartList.stream().map(cart -> new ProductDTO(cart)).collect(Collectors.toList());
}
@Getter
@Setter
public class ProductDTO {
private int productId;
private String productName;
private int cartId;
private String optionName;
private int quantity;
private int price;
public ProductDTO(Cart cart) {
this.productId = cart.getOption().getProduct().getId();
this.productName = cart.getOption().getProduct().getProductName(); //Lazy Loading
this.cartId = cart.getId();
this.optionName = cart.getOption().getOptionName(); //Lazy Loading
this.quantity = cart.getQuantity();
this.price = cart.getPrice();
}
}
}
}
cart에서 product, option 등 필요한 것을 getter로 꺼내는 코드
위 구조로 전송시 프론트에서 응답받은 DTO 처리를 해야한다... 프론트에서 연산이 많아지는 것은 좋지 않다.
(프론트는 뷰에 데이터 렌더링 잘하는 것에 집중)
서비스
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class CartService {
private final OptionJPARepository optionJPARepository;
private final CartJPARepository cartJPARepository;
public CartResponse.FindAllDTOv2 findAllv2(User user) {
List<Cart> cartList = cartJPARepository.findByUserIdOrderByOptionIdAsc(user.getId());
return new CartResponse.FindAllDTOv2(cartList);
}
}
레포지토리
public interface CartJPARepository extends JpaRepository<Cart, Integer> {
@Query("select c from Cart c where c.user.id = :userId order by c.option.id asc")
List<Cart> findByUserIdOrderByOptionIdAsc(int userId);
}
fetch join이 아니므로 Cart에서 Lazy 로딩인 객체(product, option)은 프록시로 가져오는데,
DTO 생성시 product와 option 속성 조회시 Lazy 로딩이 걸린다.
Controller
@GetMapping("/carts/v2")
public ResponseEntity<?> findAllv2(@AuthenticationPrincipal CustomUserDetails userDetails) {
CartResponse.FindAllDTOv2 responseDTO = cartListService.findAllv2(userDetails.getUser());
ApiUtils.ApiResult<?> apiResult = ApiUtils.success(responseDTO);
return ResponseEntity.ok(apiResult);
}
장바구니 업데이트
응답 JSON
{
"success": true,
"response": {
"carts": [
{
"cartId": 30,
"optionId": 8,
"optionName": "밤깎기+다회용 구이판 세트",
"quantity": 1,
"price": 5500
},
{
"cartId": 31,
"optionId": 5,
"optionName": "2겹 식빵수세미 6매",
"quantity": 1,
"price": 8900
}
],
"totalPrice": 592300
},
"error": null
}
UpdateDTO
@Getter
@Setter
public static class UpdateDTO {
private List<CartDTO> carts;
private int totalPrice;
public UpdateDTO(List<Cart> cartList) {
this.carts = cartList.stream().map(CartDTO::new).collect(Collectors.toList());
this.totalPrice = cartList.stream().mapToInt(cart -> cart.getPrice()).sum();
}
@Getter
@Setter
public class CartDTO {
private int cartId;
private int optionId;
private String optionName;
private int quantity;
private int price;
public CartDTO(Cart cart) {
this.cartId = cart.getId();
this.optionId = cart.getOption().getId();
this.optionName = cart.getOption().getOptionName();
this.quantity = cart.getQuantity();
this.price = cart.getPrice();
}
}
}
서비스
@Transactional
public CartResponse.UpdateDTO update(List<CartRequest.UpdateDTO> requestDTOs, User user) {
List<Cart> cartList = cartJPARepository.findAllByUserId(user.getId());
// 1. 유저 장바구니에 아무것도 없으면 예외처리
// 2. cartId:1, cartId:1 이렇게 requestDTOs에 동일한 장바구니 아이디가 두번 들어오면 예외처리
// 3. 유저 장바구니에 없는 cartId가 들어오면 예외처리
// 위에 3개를 처리하지 않아도 프로그램은 잘돌아간다. 예를 들어 1번을 처리하지 않으면 for문을 돌지 않고, cartList가 빈배열 []로 정상응답이 나감.
for (Cart cart : cartList) {
for (CartRequest.UpdateDTO updateDTO : requestDTOs) {
if (cart.getId() == updateDTO.getCartId()) {
cart.update(updateDTO.getQuantity(), cart.getOption().getPrice() * updateDTO.getQuantity());
}
}
}
return new CartResponse.UpdateDTO(cartList);
} // 더티체킹
서비스 코드를 완성시켜야한다.
'Spring > 카테캠 - TIL' 카테고리의 다른 글
카카오테크캠퍼스 4주차 코드리뷰 (0) | 2023.07.28 |
---|---|
TIL [0727] : 기능 구현, AOP 개념 정리 (0) | 2023.07.27 |
TIL [0725] : 5주차 강의 - 코드 리팩토링(GlobalExceptionHandler, AOP) (0) | 2023.07.25 |
카카오테크캠퍼스 : 3주차 코드리뷰 (0) | 2023.07.21 |
카테캠 4주차 과제 중 문제 - 해결 (유효성 검사, Mockito 인자+anyInt, UserDetails 직접 주입) (0) | 2023.07.21 |