스프링부트와 JPA를 활용해 웹 애플리케이션을 개발한다.
다음 코드 및 진행 방법은 인프런 김영한 강사님의 유로 강의 내용을 발췌한 내용이다.
강의 : <실전! 스프링 부트와 JPA 활용 1 - 웹 애플리케이션 개발>
출처 : https://inf.run/zzKt
강의를 들으며 추가로 궁금한 내용과 제대로 이해되지 않은 부분을 정리한다.
이번 강의 간 '프레젠테이션 계층'이 자주 등장 한다.
프레젠테이션 계층이란 클라이언트의 요청/응답을 처리하는 계층으로 Controller가 이 계층에 속한다.
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
// @RequestBody는 json으로 온 body를 멤버로 바꿔줌
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
Member member = new Member();
member.setName(request.getName());
// Entity user -> username으로 변경하면 member.setName이 컴파일 단계에서 오류나서 Api는 영향 받지 않음.
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
@Data
static class CreateMemberRequest {
@NotEmpty // Entity가 아닌 여기에 선언 시 api 스펙에 딱 맞출 수 있어 유지보수가 수월해짐
private String name;
}
@PutMapping("/api/v2/members/{id}")
public UpdateMemberResponse updateMemberV2(
@PathVariable("id") Long id,
@RequestBody @Valid UpdateMemberRequest request){
memberService.update(id, request.getName());
Member findMember = memberService.findOne(id);
// 커맨드와 쿼리를 분리해 memberService.update()에서 id를 return 받지않고 조회 후 객체를 만들어 그 값을 리턴함
return new UpdateMemberResponse(findMember.getId(), findMember.getName());
}
@Data
static class UpdateMemberRequest{ // 별도 DTO
private String name;
}
@Data
@AllArgsConstructor
static class UpdateMemberResponse{ // Id
private Long id;
private String name;
}
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); // .getMember()까지는 프록시 객체 상태, .getName()은 실제 값을 끌고 와야됨 -> LAZY 강제 초기화
order.getDelivery().getAddress(); // 똑같이 LAZY 강제 초기화
}
return all;
}
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> orderV2() {
//ORDER 2개
//N + 1 -> 1 + 회원 N + 배송 N
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
@Data
static class SimpleOrderDto{
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName(); // LAZY 초기화 * 영속성 컨텍스트가 DB에 쿼리를 날려 데이터를 가져옴
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); // LAZY 초기화 * 영속성 컨텍스트가 DB에 쿼리를 날려 데이터를 가져옴
}
}
위 방식은 Entity를 외부에 노출하지 않고, 좀 더 적은 Query만 보내 최적화 할 수 있다.
다만 order -> member / order -> delivery는 지연 로딩, 즉 지연 로딩 조회가 최악의 경우 1+ 2 + 2번(N+1문제) 발생한다.
최악의 경우라고 하는 이유는 지연로딩은 영속성 컨텍스트에서 조회하는데,
한번 조회된 쿼리는 영속성 컨텍스트에 있는 값을 가져오기 때문에 조회하지 않아서 최악의 경우(최대)의 상황을 가정한다.
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> orderV3() {
List<Order> orders = orderRepository.findAllWithMemeberDelivery(); // LAZY 상관 없이 실제 값을 끌고 온다.
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return result;
}
public List<Order> findAllWithMemeberDelivery() { // 재사용성이 좋음
return em.createQuery(
"select o from Order o" + // order와 member, order와 delivery를 join 후 한번에 다 select
" join fetch o.member m" + // LAZY를 무시하고 프록시가 아닌 실제 값을 가져옴
" join fetch o.delivery d", Order.class
).getResultList();
}
orderV3같이 fetch join을 이용하면 N+1 문제를 해결할 수 있다!
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryReopository {
private final EntityManager em;
public List<OrderSimpleQueryDto> findOrderDtos() { // 해당 Dtos를 쓸 때만 사용 가능..단 V3보다 성능차이가 좀 더 좋음
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
" from Order o" + // 매핑하기 위해선 new 연산자 필수 사용
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class) // o가 Dto와 매핑 될 수 없음
.getResultList();
}
}
결론적으로 Entity가 아닌 API 스펙에 맞춘 별도 DTO들을 생성해 사용하고 필요하면 Fetch Join으로 성능을 최적화한다!
@RequiredArgsConstructor : final 또는 @Notnull이 붙은 필드의 생성자를 자동 생성
@ResponseBody : 데이터를 JSON 객체로 반환
@RestController : ResponseBody + controller → 데이터를 JSON 형태로 객체 반환한다. * @RestController를 명시하면 @ResponseBody를 지정해주지 않아도 된다.
@JsonIgnore : 해당 어노테이션이 붙은 값은 Json 데이터에 Null 지정된다(데이터 값 무시)
@PostConstruct : 의존성 주입이 완료된 시점 이후에 실행되어야 되는 method에 붙임
호출 순서
- 생성자 호출 2. 의존성 주입 3. @PostConstruct
- 해당 어노테이션을 사용하면 빈이 초기화 됨과 동시에 의존성 확인 가능.
추가로 컬렉션을 직접 반환하면 항후 API 스펙을 변경하기 어렵다.(별도의 Result 클래스 생성으로 해결)