블로그

양성빈

[인프런 워밍업 스터디 클럽] 0기 백엔드 미션 - JPA 테스트 (Day7)

미션어느덧 스터디 클럽 7일차가 되었다. 오늘은 이전에 JDBC를 이용한 서비스 로직을 JPA로 변경해보는 실습을 가졌다.그럼 이제 미션을 수행해보자.진도표 7일차와 연결됩니다우리는 JPA라는 개념을 배우고 유저 테이블에 JPA를 적용해 보았습니다. 몇 가지 문제를 통해 JPA를 연습해 봅시다! 🔥문제 1문제1의 요구사항은 과제6에서 만들었던 기능들을 JPA로 구현하라고 하셨다. 따라서 강의에서 코치님께서 보여주신 과정으로 진행해보려고 한다. step0. application.yml jpa 설정 추가spring: datasource: url: "jdbc:mysql://localhost/fruit" username: "root" password: "" driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none properties: hibernate: show_sql: true format_sql: true dialect: org.hibernate.dialect.MySQL8Dialectstep1. Fruit Entity를 JPA Entity화 하기!package me.sungbin.entity.fruit; import jakarta.persistence.*; import org.hibernate.annotations.Comment; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.entity.fruit * @fileName : Fruit * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Entity public class Fruit { @Id @Comment("Fruit 테이블의 Primary key") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(name = "warehousingDate", nullable = false) private LocalDate warehousingDate; @Column(nullable = false) private Long price; @Column(nullable = false) private boolean isSold = false; public Fruit() { } public Fruit(String name, LocalDate warehousingDate, Long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public Fruit(String name, LocalDate warehousingDate, Long price, boolean isSold) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public Fruit(Long id, String name, LocalDate warehousingDate, Long price, boolean isSold) { this.id = id; this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.isSold = isSold; } public Long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public Long getPrice() { return price; } public boolean isSold() { return isSold; } public void updateSoldInfo(boolean isSold) { this.isSold = isSold; } } Entity 어노테이션을 붙여서 엔티티로 만들고 기본 primary key와 auto_increment를 설정한다.그 외에, 컬럼들의 null 여부도 설정하였다.또한 warehousingDate의 필드에 컬럼 이름을 다시 넣은 이유는 mysql 쿼리가 동작할 때 warehousingDate로 컬럼이 인식이 안되고 warehousing_date로 인식을 하기 때문에 name 필드를 넣었다.step2. JpaRepository를 상속받은 인터페이스 생성기존의 FruitRepository 인터페이스를 FruitJdbcRepository로 파일명을 변경한 후, FruitRepository 클래스를 만든다.FruitJPARepository로 만들어도 상관은 없지만, 통상적으로 편하게 FruitRepository로 해주는 것이다.package me.sungbin.repository; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public interface FruitRepository extends JpaRepository<Fruit, Long> { } step3. DTO 코드 변경package me.sungbin.dto.fruit.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import me.sungbin.entity.fruit.Fruit; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : SaveFruitInfoRequestDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class SaveFruitInfoRequestDto { @NotBlank(message = "과일 이름이 공란일 수 없습니다.") @NotNull(message = "과일 이름이 null일 수는 없습니다.") private String name; @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate warehousingDate; @Min(value = 0, message = "가격이 음수가 될 수는 없습니다.") private long price; public SaveFruitInfoRequestDto(String name, LocalDate warehousingDate, long price) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public Fruit toEntity() { return new Fruit(name, warehousingDate, price, false); } }과일 정보를 저장할 때, toEntity() 메서드에 Fruit 생성자의 마지막애 false를 추가하였다. 왜냐하면 DTO에서 엔티티로 변경을 할 때 판매유무를 확실히 미판매로 해두려고 하기 때문이다. step4. 서비스 로직 수정기존 FruitService를 FruitJdbcService로 변경하고 FruitService를 새로 만든다.일단 먼저 서비스 코드를 전체 보여주겠다. package me.sungbin.service; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } }위의 코드를 보면 나머지는 대략 이해는 되는데 getFruitInfo의 getFruitSalesInfo 메서드는 처음 볼 것이다. 우리가 배운 범위에서getFruitSalesInfo는 data jpa에서 기본으로 제공해주는 함수는 아니기 때문이다. 바로 이것은 repository에 @Query 어노테이션과 사용자 정의 JPQL 쿼리를 사용하였다. 그 이유는 집계함수로 인하여 불기파 사용하였다. 아래는 수정된 repository 코드이다.package me.sungbin.repository; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 } step5. 테스트이제 위의 변경된 코드를 가지고 테스트를 해서 검증해보자. fruit 테이블을 조회하면 아래처럼 비어있다고 하자.생성 테스트그리고 몇개의 데이터를 만들고 테이블에 잘 insert 되었는지 확인해보았다.수정합산 조회현재 데이터의 테이블이 아래와 같다고 하자.그러면 테스트 해보자.step6. 테스트 코드이전과 같은 아래의 테스트코드로 실행 해보았다.package me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_success() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } }문제 2요구사항은 우리가게를 거쳐갔던 과일의 개수를 구하는 문제이다. 여기서 의도는 거쳐갔던이므로 판매가 되었던 것중의 과일의 이름을 카운트해보겠다. step0. 응답 DTO 생성 package me.sungbin.dto.fruit.response; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : CountFruitNameResponseDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class CountFruitNameResponseDto { private final long count; public CountFruitNameResponseDto(long count) { this.count = count; } public long getCount() { return count; } }step1. 레파지토리에 jpa 메서드 선언package me.sungbin.repository; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 long countByNameAndIsSoldIsTrue(String name); } countByNameAndIsSoldIsTrue 메서드가 방금 작성한 코드이다.step2. 서비스 코드 작성package me.sungbin.service; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } /** * 문제 2번 * @param name * @return */ public CountFruitNameResponseDto countFruitName(String name) { long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name); return new CountFruitNameResponseDto(count); } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } } countFruitName 메서드가 내가 방금 작성한 메서드이다.step3. 컨트롤러 코드 작성package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.service.FruitService; import org.springframework.web.bind.annotation.*; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } @GetMapping("/count") public CountFruitNameResponseDto countFruitName(@RequestParam String name) { return this.fruitService.countFruitName(name); } }countFruitName이 방금 작성한 컨트롤러 코드이다.step4. 테스트아래와 같이 DB 데이터가 있다고 하자. 그리고 포스트맨으로 테스트해보자.step5. 테스트 코드@Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 성공") void lesson7_question2_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); }이번에는 실패 케이스와 성공 케이스 2개를 작성했으며 결과는 아래와 같다.문제 3문제 3은 아직 판매되지 않은 과일 정보 리스트 중에 특정 금액 이상 혹은 이하의 과일 목록을 받는 것이다. step0. 응답 DTO 생성package me.sungbin.dto.fruit.response; import java.time.LocalDate; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.response * @fileName : FruitResponseDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class FruitResponseDto { private final String name; private final long price; private final LocalDate warehousingDate; public FruitResponseDto(String name, long price, LocalDate warehousingDate) { this.name = name; this.price = price; this.warehousingDate = warehousingDate; } public String getName() { return name; } public long getPrice() { return price; } public LocalDate getWarehousingDate() { return warehousingDate; } }step1. 요청 DTO 생성package me.sungbin.dto.fruit.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; /** * @author : rovert * @packageName : me.sungbin.dto.fruit.request * @fileName : FruitRequestDto * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ public class FruitRequestDto { @NotBlank(message = "option은 공란일 수 없습니다.") @NotNull(message = "option은 반드시 있어야 합니다.") private final String option; private final long price; public FruitRequestDto(String option, long price) { this.option = option; this.price = price; } public String getOption() { return option; } public long getPrice() { return price; } }요청 DTO에는 spring starter validation을 추가하여 예외 처리도 해두었다.step2. Repository의 쿼리 메서드 추가쿼리 메서드 대신에 @Query를 사용하여 DTO로 반환시킬 수 있다. 과제 7의 1번처럼 말이다. 하지만 본 과제의 취지와 맞지 않은 것 같기에 과제7의 1번(문제 3번)은 @Query로 사용했으니 이번엔 안 사용하고 해보겠다.package me.sungbin.repository; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.repository * @fileName : FruitRepository * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional(readOnly = true) public interface FruitRepository extends JpaRepository<Fruit, Long> { @Query("SELECT new me.sungbin.dto.fruit.response.GetFruitResponseDto(SUM(case when f.isSold = true then f.price else 0 end), SUM(case when f.isSold = false then f.price else 0 end)) FROM Fruit f WHERE f.name = :name") GetFruitResponseDto getFruitSalesInfo(@Param("name") String name); // 과제 6 문제 3번 long countByNameAndIsSoldIsTrue(String name); List<Fruit> findAllByPriceGreaterThanEqualAndIsSoldIsFalse(long price); List<Fruit> findAllByPriceLessThanEqualAndIsSoldIsFalse(long price); }step3. 서비스 코드 추가package me.sungbin.service; import me.sungbin.dto.fruit.request.FruitRequestDto; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.entity.fruit.Fruit; import me.sungbin.repository.FruitRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** * @author : rovert * @packageName : me.sungbin.service * @fileName : FruitService * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Service @Transactional(readOnly = true) public class FruitService { private final FruitRepository fruitRepository; public FruitService(FruitRepository fruitRepository) { this.fruitRepository = fruitRepository; } /** * 문제 1번 (과제6 문제 1) * @param requestDto */ @Transactional public void saveFruitInfo(SaveFruitInfoRequestDto requestDto) { Fruit fruit = requestDto.toEntity(); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 2) * @param requestDto */ @Transactional public void updateFruitInfo(UpdateFruitRequestDto requestDto) { Fruit fruit = this.fruitRepository.findById(requestDto.getId()).orElseThrow(IllegalArgumentException::new); fruit.updateSoldInfo(true); this.fruitRepository.save(fruit); } /** * 문제 1번 (과제 6 문제 3) * @param name * @return */ public GetFruitResponseDto getFruitInfo(String name) { GetFruitResponseDto fruitData = this.fruitRepository.getFruitSalesInfo(name); validateGetFruitAmount(fruitData.getSalesAmount(), fruitData.getNotSalesAmount()); return fruitData; } /** * 문제 2번 * @param name * @return */ public CountFruitNameResponseDto countFruitName(String name) { long count = this.fruitRepository.countByNameAndIsSoldIsTrue(name); return new CountFruitNameResponseDto(count); } public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) { if (Objects.equals(requestDto.getOption(), "GTE")) { return this.fruitRepository.findAllByPriceGreaterThanEqualAndIsSoldIsFalse(requestDto.getPrice()) .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate())) .collect(Collectors.toList()); } else if (Objects.equals(requestDto.getOption(), "LTE")) { return this.fruitRepository.findAllByPriceLessThanEqualAndIsSoldIsFalse(requestDto.getPrice()) .stream().map(fruit -> new FruitResponseDto(fruit.getName(), fruit.getPrice(), fruit.getWarehousingDate())) .collect(Collectors.toList()); } else { throw new IllegalArgumentException("옵션은 GTE 혹은 LTE이여야 합니다."); } } private void validateGetFruitAmount(long salesAmount, long notSalesAmount) { if (salesAmount == 0L && notSalesAmount == 0L) { throw new IllegalArgumentException("존재하는 과일이 없습니다."); } } } 옵션이 올바르지 못할 경우 런 타임 에러 발생step4. 컨트롤러 코드 추가package me.sungbin.controller.fruit; import jakarta.validation.Valid; import me.sungbin.dto.fruit.request.FruitRequestDto; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import me.sungbin.dto.fruit.response.CountFruitNameResponseDto; import me.sungbin.dto.fruit.response.FruitResponseDto; import me.sungbin.dto.fruit.response.GetFruitResponseDto; import me.sungbin.service.FruitService; import org.springframework.web.bind.annotation.*; import java.util.List; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitController * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @RestController @RequestMapping("/api/v1/fruit") public class FruitController { private final FruitService fruitService; public FruitController(FruitService fruitService) { this.fruitService = fruitService; } @PostMapping public void saveFruitInfo(@RequestBody @Valid SaveFruitInfoRequestDto requestDto) { this.fruitService.saveFruitInfo(requestDto); } @PutMapping public void updateFruitInfo(@RequestBody UpdateFruitRequestDto requestDto) { this.fruitService.updateFruitInfo(requestDto); } @GetMapping("/stat") public GetFruitResponseDto getFruitInfo(@RequestParam String name) { return this.fruitService.getFruitInfo(name); } @GetMapping("/count") public CountFruitNameResponseDto countFruitName(@RequestParam String name) { return this.fruitService.countFruitName(name); } @GetMapping("/list") public List<FruitResponseDto> findSoldFruitListOfPrice(FruitRequestDto requestDto) { return this.fruitService.findSoldFruitListOfPrice(requestDto); } }  step5. 테스트현재 DB 데이터는 아래와 같다.그럴때 테스트를 해보겠다.GTELTEstep6. 테스트 코드이제 테스트 코드를 작성해보자. 아래는 전체 테스트 코드다!package me.sungbin.controller.fruit; import com.fasterxml.jackson.databind.ObjectMapper; import me.sungbin.dto.fruit.request.SaveFruitInfoRequestDto; import me.sungbin.dto.fruit.request.UpdateFruitRequestDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author : rovert * @packageName : me.sungbin.controller.fruit * @fileName : FruitControllerTest * @date : 2/26/24 * @description : * =========================================================== * DATE AUTHOR NOTE * ----------------------------------------------------------- * 2/26/24 rovert 최초 생성 */ @Transactional @SpringBootTest @AutoConfigureMockMvc class FruitControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("문제 1번 통합 테스트 - 실패 (가격이 음수거나 과일 이름이 공란)") void question1_test_fail_caused_by_price_is_minus_or_fruit_name_is_empty() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("", LocalDate.of(2024, 1, 1), -1000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("문제 1번 통합 테스트 - 성공") void question1_test_success() throws Exception { SaveFruitInfoRequestDto requestDto = new SaveFruitInfoRequestDto("파인애플", LocalDate.of(2024, 2, 2), 20000); this.mockMvc.perform(post("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 2번 통합 테스트 - 성공") void question2_test_success() throws Exception { UpdateFruitRequestDto requestDto = new UpdateFruitRequestDto(1); this.mockMvc.perform(put("/api/v1/fruit") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(requestDto))) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("문제 3번 통합 테스트 - 성공") void question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/stat") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question2_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 성공") void lesson7_question2_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/count") .param("name", "사과")) .andDo(print()) .andExpect(status().isOk()); } @Test @DisplayName("과제 7번 문제 2번 통합 테스트 - 실패 (파라미터 존재 X)") void lesson7_question3_fail_test_caused_by_not_exists_request_parameter() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/list")) .andDo(print()) .andExpect(status().isBadRequest()); } @Test @DisplayName("과제 7번 문제 3번 통합 테스트 - 성공") void lesson7_question3_test_success() throws Exception { this.mockMvc.perform(get("/api/v1/fruit/list") .param("option", "GTE") .param("price", "3000")) .andDo(print()) .andExpect(status().isOk()); } }회고JPA의 편리함을 많이 깨닫는 하루였다. 하지만 쿼리메서드를 작성할 때 조건이 엄청 길어지는 것이 내가 보기엔 단점 같다.아래의 짤이 있다. JPA도 이런 취급을 받을 날이 안 왔으면 하는 마음에서 글을 마무리하려 한다. 📚 참고https://m.blog.naver.com/PostView.naver?blogId=190208&logNo=222145961004&categoryNo=51&proxyReferer=

백엔드인프런워밍업스터디클럽JPA

자바 ORM 표준 JPA 프로그래밍 기본편(김영한) 2

프로젝션 JPQL의 경우 패키지명까지 참조해서 생성자 매핑시켜주기 ex) select com.myproject.myapp.dto.MemberDto(m.id, m.name) ... result class 또한 DTO로 매핑 querydsl의 경우 @QueryProjection을 통해 querydsl용 생성자를 만들어서 매핑 가능 이 경우, select절에 Projections.constructor(Dto.class, ...)로 매핑 Projections.bean과 Projections.field에 비해 연쇄 수정이 적어지는 장점이 있음   페치 조인 페치 조인은 연관관계 엔티티까지 한 번에 불러오는 것 일대일 연관관계에서 주로 사용 일대다 컬렉션 페치 조인은 한 번만 지원되며,사용 시 중복조회가 일어나기 때문에 select distinct 사용하여야함 @BatchSize() 또는 hibernate.default_batch_fetch_size 사용해서 fetch join으로 불러올 엔티티 개수 지정 가능 원하는 필드만 조회하거나 연관관계가 조건절에만 필요한 경우 fetch join이 아닌 일반 조인 사용할 것.   다형성 쿼리 상속 연관관계 SINGLE_TABLE 타입의 경우type(i) in (Book, Movie) 또는 treat(i as Book).author = 'kim' 으로 사용   정적 재사용 쿼리 @SqlResultSetMapping(name = "NamedQuery 이름",entities = {    @EntityResult(entityClass=Member.class,                                   fields = { @FieldResult(name = 'id', column = 'order_id') }                                   column이 실제 테이블 컬럼명, 타입도 지정 가능                                  )           },columns = {@ColumnResult(name = 'item_name')}이것을 통해 projection 필드(가공한 필드)를 가져올 수 있음) NamedQuery 재사용 가능한 정적 쿼리 - 파라미터도 전달 가능 어플리케이션 로딩 시점에 쿼리 검증함 @Query도 NamedQuery @NamedQuery(name = "", query = "") NamedNativeQuery name, query, resultClass   벌크연산 변경감지만으로 데이터 수정하면 업데이트 쿼리가 남발됨. update, delete, insert select 사용 execute하면 영향받은 엔티티수 반환 벌크 연산은 DB에 직접 쿼리 날리기 때문에 영속성 컨텍스트 초기화가 필요@Modifying 또는 em.flush(); em.clear();        

JPA강의김영한프로젝션페치조인다형성쿼리Named쿼리벌크연산

이혜리

[인프런 워밍업 클럽 스터디1기] 백엔드 - 7차 과제

진도표 7일차와 연결됩니다우리는 JPA라는 개념을 배우고 유저 테이블에 JPA를 적용해 보았습니다. 몇 가지 문제를 통해 JPA를 연습해 봅시다! 🔥     문제1JPA(Java Persistence API) 는 자바 객체를 관계형 데이터 베이스에 영속적으로 저장하고 조회할 수 있는 ORM 기술에 대한 표준 명세를 의미한다.JPA 를 통해 SQL 쿼리를 작성하지 않고도 객체를 통해 데이터베이스를 조작할 수 있어 보수성이 향상된다.Entity 클래스를 작성한 후 Repository 인터페이스를 생성해야하는데, JpaRepository를 상속받도록 하면, 기본적인 쿼리 추가, 조회, 수정, 삭제, findAll(), findById() 등의 메서드를 사용할 수 있다.1) Entity 클래스 정의@Entity public class Fruits { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; @Column(nullable = false, length = 20 ) private String name; @Column(nullable = false) private LocalDate warehousingDate; @Column(nullable = false) private long price; @Column(nullable = false) private int saled; public Fruits(){ } public Fruits(long id, String name, LocalDate warehousingDate, long price, int saled) { this.id = id; this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.saled = saled; } public Fruits(String name, LocalDate warehousingDate, long price, int saled) { this.name = name; this.warehousingDate = warehousingDate; this.price = price; this.saled = saled; } public Long getId() { return id; } public String getName() { return name; } public LocalDate getWarehousingDate() { return warehousingDate; } public long getPrice() { return price; } public int getSaled(){ return saled; } public void setSaled(int saled) { this.saled = saled; } }JPARepository를 사용하여 액세스할 엔티티 클래스를 정의했다.@Entity 어노테이션은 JPA를 사용해 테이블과 매핑할 클래스에 붙여주는 어노테이션이다. 이 어노테이션을 붙이면, JPA가 해당 클래스를 관리하게 된다.@Id 어노테이션은 특정 속성을 기본키로 설정하는 어노테이션이다.@GeneratedValue(startegy = GenerationType.IDENTITY) 은 기본키 생성을 DB에 위임한다는 것으로 위 클래스에서는 id 값이 자동 생성된다.@Column 은 객체필드를 테이블 칼럼과 매핑한다. 2) JpaRepository 인터페이스 상속받는 인터페이스 생성public interface FruitRepository extends JpaRepository<Fruits,Long> { Optional<Fruits> findById(Long id); long countByNameAndSaled(String name,int i); long countByName(String name); List<Fruits> findByPriceGreaterThan(long price); List<Fruits> findByPriceLessThan(long price); List<Fruits> findBySaled(int i); } 문제2특정 과일을 기준으로 지금까지 우리 가게를 거쳐갔던 과일 개수를 세고 싶습니다.controller 클래스에서는 아래와 같이 getmapping 부분을 만들고@GetMapping("/api/v1/fruit/count") @Description("지금까지 거쳐간 특정 과일이름의 개수를 반환(7일차-문제2)") public FruitCountResponse countName(@RequestParam String name){ return fruitService.countName(name); }service 클래스에서는  public FruitCountResponse countName(String name) { Long result = fruitRepository.countByName(name); return new FruitCountResponse(result); }위와 같이 FruitCountResponse 객체를 반환하도록 한다.FruitCountResponse  클래스는 아래와 같이 간단하게 json 형식으로 "count" : 숫자 를 반환하도록 만들었다.public class FruitCountResponse { private long count; public FruitCountResponse(long count) { this.count = count; } public long getCount() { return count; } }아래와 같이 HTTP 응답 Body 예시와 같은 형식으로 나오는 것을 볼 수 있다.  문제3 아직 판매되지 않은 특정 금액 이상 혹은 특정 금액 이하의 과일 목록을 받아보고 싶습니다.controller 클래스에서는 @GetMapping("/api/v1/fruit/list") @Description("판매되지 않은 특정 금액 이상 혹은 특정 금액 이하의 과일 목록(7일차-문제3)") public List<Fruits> getListFruit(@RequestParam String option, @RequestParam long price){ return fruitService.overviewFruitCondition(option,price); }service 클래스에서는public List<Fruits> overviewFruitCondition(String option, long price) { List<Fruits> list; List<Fruits> result = new ArrayList<>(); if (option.equals("GTE")) list = fruitRepository.findByPriceGreaterThan(price); else if (option.equals("LTE")){ list = fruitRepository.findByPriceLessThan(price); }else throw new IllegalArgumentException("GTE, LTE 로 작성해주세요."); for (Fruits e : list) if (e.getSaled() == 1) result.add(e); return result; }위와 같이 List<Fruits> 를 반환하도록 한다.   위와 같이 조건을 만족하는 (6000 이하의 과일들) 결과를 출력한다. 회고직접 JpaRepository를 상속받는 repository와 entity 를 만들어 실습해보니, 약간 익숙해진 것 같다. 위 과제를 하는 중간중간 오류(1,2) 가 났었는데,1) getter of property ‘id’ threw exceptionhttps://stackoverflow.com/questions/25234892/org-springframework-beans-invalidpropertyexception-invalid-property-id-of-bea org.springframework.beans.InvalidPropertyException: Invalid property 'id' of bean classI dont understand why I am getting this error on the save below. Any clue? org.springframework.beans.InvalidPropertyException: Invalid property 'id' of bean class [com.test.DataException]: Getter...stackoverflow.com위 오류에서는 long에서 Long으로 id 타입을 바꾸니 해결되었다.2) Unknown column 'warehousing_date' in 'field list'위 오류에서는 warehousing_date 라는 칼럼명을 못 찾아서 데이터를 save 할 수 없는 상황이였는데,변수 값을 잘못 넣어서 오류가 났던 거라 바꾸니 해결되었다.

백엔드백엔드7차과제JPAEntity

이혜리

[인프런 워밍업 클럽 스터디1기] 백엔드 - 2주차 회고

2주차 정리 및 회고Section3. 역할의 분리와 스프링 컨테이너클린코드클린코드 : 함수는 최대한 작게 만들고 한 가지 일만 수행하는 것이 좋다. UserController.java 가api 진입점,현재 유저가 있는지 없는지 확인하고 예외처리,SQL을 사용해 실제 database와의 통신  을 담당하는 3가지 역할을 다 하고 있으므로, 클린코드가 아니다. 이러한 상태의 단점은 너무 큰 기능이기 때문에 테스트도 힘들다.종합적으로 유지보수성이 매우 떨어진다.따라서 Controller를 3단 분리하여 클린 코드로 작성하였다. 기존 api 진입점으로써 HTTP Body를 객체로 변환의 역할은 Controller 의 역할로 남겨두고,현재 유저가 있는지 없는지 확인하고 예외처리 의 역할은 Service가,SQL을 사용해 실제 database와의 통신 의 역할은 Repository가 담당한다. 스프링 컨테이너서버가 시작되면, 스프링 컨테이너(클래스 저장소)가 시작된다. 스프링 빈들(클래스들) 이 등록되고 - dependency 주입된, 사용자가 직접 설정해준 스프링 빈이 등록된다. 이때 필요한 의존성이 자동으로 설정된다.예를 들어, UserController에서 필요한 JdbcTemplate이 자동으로 생성자 내로 들어간다. 위와 같이 Book 관련 3단 분리 코드를 예시로 봤다.이때, 두 repository 중 어떤 것을 우선순위로 하는지는 @Primary @Qualifier 어노테이션을 사용하면 된다. Section4. 생애 최초 JPA 사용하기JPA 사용지금까지 작성한 코드를 살펴보면 아쉬운 몇 가지가 있다.repository 클래스 내에서 문자열로 쿼리를 작성하기 때문에 실수할 수 있고, 실수를 인지하는 시점이 느리다.특정 데이터베이스에 종속적이게 된다. 우리의 경우엔 MySql반복 작업이 많아진다.데이터베이스의 테이블과 객체의 매핑되는 패러다임이 다르다.따라서 JPA (Java Persistence API) 데이터를 영구적으로 보관하기 위해 java 진영에서 정해진 규칙을 사용한다.즉, 객체와 관계형 데이터베이스 테이블을 짝지어 데이터를 영구적으로 저장할 수 있도록 돕는다.이를 사용하기 위해서는 Hibernate 가 필요하다.직접 매핑해보자유저 테이블에 대응되는 entity class 인 User.java 를 만들면 다음과 같이 코드가 수정된다.package com.group.libraryapp.domain; import org.springframework.lang.Nullable; import javax.persistence.*; @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id = null; @Column(nullable = false, length = 20, name = "name" ) //name varchar(20) private String name; @Column(nullable = false) private Integer age; protected User() { } public User(String name, Integer age) { if (name == null || name.isBlank()){ throw new IllegalArgumentException(String.format("잘못된 name(%s)이 들어왔습니다.")); } this.name = name; this.age = age; } public String getName() { return name; } public Integer getAge() { return age; } public Long getId() { return id; } public void updateName(String name) { this.name = name; } } 또한 jpa 를 사용하기 위해서 application.yml 파일도 변경하자.hibernate 부분을 추가해준다.spring: datasource: url: "jdbc:mysql://localhost/library" username: "root" password: "1234" driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none properties: hibernate: show_sql : true format_sql : true dialect : org.hibernate.dialect.MySQL8Dialect😀 spring.jpa.hibrenate.ddl-auto : none스프링이 시작할 때 DB에 있는 테이블을 어떻게 처리할지에 대한 옵션이다. 현재 DB에 테이블이 잘 만들어져 있고, 미리 넣어둔 데이터도 있으므로 별 다른 조치를 하지 않는다. 자동으로 쿼리 날리기 (기본)repository 폴더 내에 UserRepository interface를 만들어준 뒤,JpaRepository 를 상속받게 해준다.package com.group.libraryapp.repository.user; import com.group.libraryapp.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByName(String name); }UserService.java에서save, findAll, delete 등의 기본적인 쿼리들은 sql 문자열을 타이핑할 필요없이 자동으로날릴 수 있게 되었다.package com.group.libraryapp.service.user; import com.group.libraryapp.domain.User; import com.group.libraryapp.repository.user.UserRepository; import com.group.libraryapp.dto.User.request.UserCreateRequest; import com.group.libraryapp.dto.User.request.UserUpdateRequest; import com.group.libraryapp.dto.User.response.UserResponse; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Service public class UserServiceV2 { private final UserRepository userRepository; public UserServiceV2(UserRepository userRepository) { this.userRepository = userRepository; } public void saveUser(UserCreateRequest request){ userRepository.save(new User(request.getName(),request.getAge())); }// INSERT SQL 이 자동으로 날라감. public List<UserResponse> getUser(){ return userRepository.findAll().stream() .map(UserResponse::new ) .collect(Collectors.toList()); } public void updateUser(UserUpdateRequest request){ User user = userRepository.findById(request.getId()) .orElseThrow(IllegalArgumentException::new); user.updateName(request.getName()); userRepository.save(user); } public void deleteUser(String name){ User user = userRepository.findByName(name) .orElseThrow(IllegalArgumentException::new); userRepository.delete(user); } }JpaRepository<Entity, ID>를 구현 받는 Repository에 대해 자동으로 기본적인 메소드(save, findAll) 를 사용할 수 있는 SimpleJpaRepository 기능을 사용할 수 있게 해준다. 그렇다면 다른 다양한 쿼리는 어떻게 작성할까?위 코드에서 삭제 기능인 deleteUser 메소드는userRepository.findByName 메소드를 사용하고 있다.이 메소드는 SimpleJpaRepository 기능이 아니라, 추가로 interface에 추상(?) 함수 정의만 해놓은 findByName 을 사용한 것이다. By 앞에는 다음과 같은 구절이 들어갈 수 있다. findfindAllexistscountBy 뒤에는 필드 이름이 들어가는데, And나 Or 로 조합될 수 있다. 또한 동등조건 (=) 외에도 다양한 조건을 사용할 수 있다.회고벌써 워밍업 클럽 스터디의 반이나 지났다. 확실히 이렇게 약간의 강제성을 부여하는 스터디가 나랑 잘 맞는 것 같다. 나 혼자서 공부했으면, 금방 흐지부지 되었을텐데 스터디 코치님들이 디스코드에 종종 올려주시는 글들이나, 다른 분들이 과제한 부분들을 읽으면서 배우는 점도 많은 것 같고, 완주러너의 혜택도 욕심이 나서 더욱 공부를 하게 되는 것 같다.

백엔드백엔드3단분리JPA2주차발자국

윤대

[인프런 워밍업 0기] Visitor Pattern 적용해보기!

이는 프로젝트 3단계를 해결하며, 새롭게 공부한 Visitor Patter에 대한 기록입니다.저는 연차 기록(LeaveRecord)과 출퇴근 기록(WorkRecord)을 근무 상태(AttendanceStatus)의 서브클래스로 상정했고, 추후에 출장이나 결근과 같이 상태가 확장될 수도 있다고 보았습니다.프로젝트 3단계, 요청 달의 연차 기록과 근무 기록을 모두 출력하는 기능을 구현하는데 있어 위의 도메인 구조는 다음과 같은 고려 사항이 있었습니다.요청 달의 모든 연차 기록과 근무 기록을 부모 클래스로 모두 호출한다.연차 사용여부를 근무를 한 일자에는 false로 하지만, 연차를 사용한 일자에는 true로 반환한다.저는 이 부분에 있어 다른 두 객체가 같은 결과를 내지만 처리 방식이 다른 경우라고 생각했습니다.그래서 이를 어떻게 해결할 지 인터넷을 뒤지다가 Visitor 패턴이라는 것을 발견하게 됩니다.Vistor 패턴Vistor 패턴은 서로 다른 상태를 가진 객체가 공통 로직을 수행해야 할 때 유용합니다.여러 객체의 공통 목표의 수행 방식이 다른 로직을 해당 객체가 아닌, Visitor라는 객체에게 위임합니다.이렇게 되면, 다음과 같은 이점이 발생합니다.객체는 자신과 관계성이 떨어지는 로직을 다른 객체에게 위임함으로써 응집도가 상승합니다.여러 객체가 공통 목표로 가진 로직이, Visitor 객체에게 집중됨으로써 관리가 용이해집니다.기존 설계를 크게 해치지 않고, 다양한 로직을 추가할 수 있습니다. Visitor 패턴을 사용하기 위해서는 2가지가 필요합니다.객체들이 Visitor를 수용할 수 있게하는 Element 인터페이스와 Visitor 인터페이스입니다.// Element 인터페이스 public interface AttendanceElement { Detail accept(Visitor visitor); } // Visitor 인터페이스 public interface Visitor { Detail visitWorkRecord(WorkRecord workRecord); Detail visitLeaveRecord(LeaveRecord leaveRecord); }Element인터페이스는 Visitor를 수용할 수 있게하는 accept 메소드만을 구현하고, 그렇게 각 객체에서 받아들여진 Visitor는 객체의 상태에 맞게 원하는 메소드를 실행하게 하면 됩니다.public abstract class AttendanceStatus implements AttendanceElement { @Override abstract public Detail accept(Visitor visitor); } public class LeaveRecord extends AttendanceStatus { @Override public Detail accept(Visitor visitor) { return visitor.visitLeaveRecord(this); } } public class WorkRecord extends AttendanceStatus { @Override public Detail accept(Visitor visitor) { return visitor.visitWorkRecord(this); } }이렇게 Element인터페이스를 상속받은 각 객체들은 accept를 구현하게 되고, Visitor를 수용해 자신의 상태에 맞는 메소드를 실행할 수 있게 되었습니다.public class AttendanceVisitor implements Visitor{ @Override public Detail visitWorkRecord(WorkRecord workRecord) { return new Detail( workRecord.getAttendanceDate(), workRecord.getWorkingMinute(), false); } @Override public Detail visitLeaveRecord(LeaveRecord leaveRecord) { return new Detail( leaveRecord.getAttendanceDate(), 0L, true); } }Service 단에서 로직을 처리할 수 있는 Visitor를 전달함으로써 손쉽게 기존 설계를 해치지 않고 로직을 처리할 수 있게 되었습니다!이번 프로젝트를 해결하며 새로운 디자인 패턴을 공부하고 적용해볼 수 있어 좋은 기회였다 생각합니다!

JPAvisitor-pattern워밍업클럽

윤대

[인프런 워밍업 0기] JPA findById() vs getReferenceById()

다음은 JPA에서 연관관계 매핑이 되어있는 객체를 save하는 상황이다. @Transactional public void recordArrivalTime(Long memberId, LocalDate attendanceDate, LocalTime startTime) { Member member = memberRepository.findById(memberId); workTimeRecordService.recordArrivalTime(member, attendanceDate, startTime); }WorkTimeRecord를 save하기 위해서는 Member를 알아야만 했고,Member를 찾기 위해서 기존에는 Id값으로 Member를 찾아왔지만, 문득 이는 굉장히 '비효율적'이라는 생각이 들었다.WorkTimeRecord를 저장하기 위해 필요한 것은 Member의 Id 값이지, Member의 모든 정보를 찾아올 필요는 없었기 때문에 불필요한 select문이 발생하는 셈이다.그래서, 이리저리 알아보던 차, Proxy객체라는 존재와 getReferenceById()라는 JpaRepository의 메소드를 알게 되었다.Proxy는 여러 곳에서 쓰이는 용어이지만, JPA를 구현하고 있는 hibernate에서는 Proxy객체는 실제 Entity(여기서는 Member)를 상속 받은 껍데기 객체이다.Entity를 상속받았기 때문에 Entity의 모든 역할을 대신 수행할 수 있으나, 내부의 값은 모두 비어있는 상태가 된다.만일, 상태가 필요한 순간이 되면 그때 JPA의 영속성 컨텍스트를 통하여 실제 Entity를 조회하여 값을 찾아오게 된다.위와 같은 기술을 Lazy라고 한다.즉, 나의 처음의 고민은 이를 통해 해결할 수 있게 되었다. @Transactional public void recordArrivalTime(Long memberId, LocalDate attendanceDate, LocalTime startTime) { Member member = memberRepository.getReferneceById(memberId); workTimeRecordService.recordArrivalTime(member, attendanceDate, startTime); }이렇게, Proxy객체로 Member를 찾게 되면, 실제로 select문은 발생하지 않고 정상적으로 WorkTimeRecord에 Member값을 할당할 수 있게 된다. 

백엔드JPA인프런워밍업MVC

자바 ORM 표준 JPA 프로그래밍 기본편(김영한) 1

JPA 정리 엔티티 매니저 엔티티 매니저 팩토리에서 엔티티 매니저들을 생성하며, 각 엔티티 매니저는 DB 커넥션 풀을 사용힌다. 엔티티 매니저를 통해서 영속성 컨텍스트 생성한다. 영속 상태는 커밋상태가 아니다. 커밋은 트랜잭션 단위이다.   영속성 컨텍스트의 특징 1차 캐시 동일성 보장 쓰기 지연(persist, flush) 변경 감지 지연 로딩(lazy)   연관관계 연관관계 주인을 판단하는 기준 : 외래키를 갖고있는가 또는 등록수정이 빈번한가 연관관계 주인에서는 컬럼에 @JoinColumn으로 조인 시 어떤 컬럼을 사용할 것인지 명시 가능하다. 연관관계 주인이 아닌 쪽에서는 mappedBy로 양방향 연관관계를 설정해줄 수 있다.양방향은 DBMS상에서는 없는 개념이지만 엔티티에서는 참조가능하다. 다대다 연관관계는 중간 테이블을 사용힌디/ - 일대다 & 다대일 테이블로 연결 cascade와 orphanRemovel를 통해 자식객체를 제어할 수 있다.부모엔티티에 있는 연관관계에서 사용 (@OneToOne 또는 @OneToMany) 프록시 객체로 조회하고, 초기화 요청들어가면(호출되면) 실제 객체에서 정보 추출캐시에 있다면 바로 실제 객체를 반환한다.   값 타입과 임베디드 타입 임베디드 타입은 객체 속성을 응집시킬 뿐만 아니라 도메인 메서드를 통해 개별적 처리가 가능하다.(객체지향적) 임베디드 타입을 한 엔티티에서 두 번 참조할 때 DB에 필드가 다르게 들어가야한다.-> 이 경우, AttributeOverride 어노테이션으로 컬럼명을 설정해줄 수 있다. 임베디드 타입은 같은 객체를 사용했을 때 객체 수정 시 모든 값이 변경될 수 있다.여러 엔티티에 공유하지 말고 각각 생성해야하며 수정 시에도 생성자를 통해 통으로 변경해야한다. 되도록이면 엔티티화(연관관계)하는 것이 식별자를 통해 추적, 변경할 수 있기 때문에 편리하다.   상속관계 @Inheritance(strategy=InheritanceType.XXX) 전략 JOINED 부모 자식 모두 테이블이 생성되며, 부모와 자식테이블은 부모의 pk로 연관관계가 이어져있다.  자식 insert 시 부모까지 insert되며, 조인을 통해서 테이블을 관리한다. SINGLE_TABLE 부모 테이블만 생성되며 자식 엔티티는 부모 테이블에 컬럼으로 추가된다. 부모클래스에 @DiscriminatorColumn(name="DTYPE; 컬럼 이름") 자식클래스에 @DiscriminatorValue("컬럼의 값으로 사용할 이름") PER_CLASS 자식 엔티티들만 독립적인 테이블을 생성하며 모두 부모 엔티티의 컬럼을 가진다. 자식 테이블의 pk는 부모 테이블의 pk를 사용한다.    

강의김영한JPAEntityManager영속성연관관계임베디드타입상속관계

spring repository 확장하기 with jpa, querydsl

JpaRepository 상속하는 repository interface 생성자동으로 빈 등록됨해당 repository는 의존주입 시 사용되는 repository public interface BoardRepository extends JpaRepository<Board, Long> {}   Querydsl을 사용하는 Custom Repository 생성 커스텀 리포지터리 interface 생성 public interface BoardRepositoryCustom { List<Object> findAll(); void updateAll(List<Long> ids, Boolean updatedStatus); } BoardRepository(JpaRepository)가 생성힌 Custom repository 또한 상속받도록 추가 public interface BoardRepository extends JpaRepository<Board, Long>, BoardRepositoryCustom {}   커스텀 리포지터리 확장 클래스 생성JPAQueryFactory는 별도로 빈등록 필요네이밍 시 repository + Impl 규칙을 사용하면 자동으로 빈등록 됨. @RequiredArgsConstructor public class BoardRepositoryImpl implements BoardRepositoryCustom{ private final EntityManager em; private final JPAQueryFactory jpaQueryFactory; @Override public List<BoardResponseDto> findAll(){ List<BoardResponseDto> results = jpaQueryFactory.select( Projections.constructor(BoardResponseDto.class, board.id, board.boardType, board.title, board.content, user.id, user.name, board.recruitingCnt, board.startDate, board.endDate, board.createdDate ) ) .from(board) .innerJoin(board.writer, user) .fetch(); return new results; } @Override public void updateAll(List<Long> ids, Boolean updatedStatus) { jpaQueryFactory.update(board) .set(board.updatedStatus, updatedStatus) .where(board.id.in(ids)) .execute(); em.flush(); em.clear(); } }  

다형성interfaceJPAQuerydsl확장repository