[JPA] 실전 -1 복습

bin1225·2023년 3월 9일
0

JPA

목록 보기
12/12
post-thumbnail

🤡실전 스프링 부트와 JPA활용 1편 복습 및 정리

조금 늦었지만, 이미 떨어져버린 망각곡선을 최대한 막기 위해...

프로젝트 환경 설정

롬복 적용

  1. Preferences plugin lombok 검색 실행 (재시작)
  2. Preferences Annotation Processors 검색 Enable annotation processing 체크 (재시작)

View 환경 설정

참고: spring-boot-devtools 라이브러리를 추가하면, html 파일을 컴파일만 해주면 서버 재시작 없이
View 파일 변경이 가능하다.
메뉴 -> build -> Recompile

JPA와 DB설정, 동작 확인

spring:
	datasource:
	url: jdbc:h2:tcp://localhost/~/jpashop
	username: sa
	password:
	driver-class-name: org.h2.Driver
jpa:
	hibernate:
	  ddl-auto: create
	properties:
	  hibernate:
		# show_sql: true
		format_sql: true
logging.level:
  org.hibernate.SQL: debug
  • spring.jpa.hibernate.ddl-auto
    - create: 기존테이블 삭제 후 다시 생성 (DROP + CREATE)
    - create-drop: create와 같으나 종료시점에 테이블 DROP
    - update: 변경분만 반영(운영DB에서는 사용하면 안됨)
    - validate: 엔티티와 테이블이 정상 매핑되었는지만 확인
    - none: 사용하지 않음(사실상 없는 값이지만 관례상 none이라고 한다.)

운영 장비에서는 절대 crate, create-drop, update 사용하면 안된다. -> 데이터가 변경되거나 날아가버릴 위험 존재.
개발 초기 단계는 create 또는 update
테스트 서버는 update 또는 validate
스테이징과 운영 서버는 validate 또는 none

도메인 분석 설계

주문과 상품의 다대다 관계를 중간에 주문 상품이라는 매핑 테이블을 이용하여 일대다, 다대일의 관계로 풀어냈다.

Member가 주문 리스트를 가지는 것이 타당해보이지만, 객체 세상은 실제 세계와 다르다. OrderMember를 참조하는 것으로 충분하다.

외래키가 있는 곳을 연관관계의 주인으로 정해라.

실제 DB테이블에서 외래키가 있는 곳을 연관관계의 주인으로 정해야, 해당 객체를 업데이트할 때 그 내부에서 외래키값도 업데이트된다. 그렇지 않은 경우에는 외래키 업데이트를 위해 추가 업데이트 쿼리가 발생하고 유지보수가 어려워진다.
연관관계의 주인이라고 해서 비즈니스 로직상 우위에 있는 것이 아니다.

엔티티 클래스 개발

엔티티 설계시 주의점

  • 엔티티에는 가급적 Setter를 사용하지 말기
    실무에서는 가급적 Getter는 열어두고 Setter는 필요한 경우에만 사용하는 것을 추천한다. Setter를 막 열어두면 엔티티에가 도대체 왜 변경되는지 추적하기 점점 힘들어지기 때문이다.
    -Setter 대신 변경을 위한 비즈니스 메서드를 별도로 설정하는 것도 방법.

  • 모든 연관관계는 지연로딩으로 설정
    - 즉시로딩은 예측하지 못한 SQL문이 실행되어, 추적하기 어렵다.
    - JPQL 사용시 N+1 문제가 발생한다.

🕵️ N+1 문제란
처음 조회할 때 SQL문 1번에 조회한 데이터의 갯수 N만큼 쿼리가 추가로 나가는 것. 즉 EAGER로 설정된 연관관계가 있는 엔티티를 N개 조회하면 해당 엔티티마다 즉시로딩으로 설정된 데이터를 추가로 조회하는 쿼리가 나간다.

@XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야
한다.

  • 컬렉션은 필드에서 초기화 하기
    - null 문제에서 안전하다.
    - 하이버네이트가 엔티티를 영속화하면, 하이버네이트가 제공하는 내장 컬렉션으로 변경한다.( 하이버네이트에서 관리하기 위함) 그런데 사용자가 다시 초기화시킬 경우 오류가 발생할 수 있다.

PK 컬럼명 설정

 @Id @GeneratedValue
 @Column(name = "member_id")
 private Long id;

테이블은 엔티티 타입처럼 타입이 없으므로 pk값을 그냥 id로 설정하면 구분이 어렵다. -> 테이블명 + i로 컬럼명을 설정

애플리케이션 구현

실습을 진행한 코드는 따로 정리하지 않고, 과정에서 사용된 기술들만 정리

  • Transactional
@Transactional(readOnly = true)
public class MemberService {
...
}

@Transactional : 트랜잭션, 영속성 컨텍스트
readOnly=true : 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨텍스트를 플러시 하지
않으므로 약간의 성능 향상(읽기 전용에는 다 적용)

테스트 케이스

테스트 케이스를 위한 설정

테스트는 케이스 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋으므로, 메모리 DB를 사용하는 것이 가장 이상적이다.
또 실제 애플리케이션 실행환경과는 다르므로 설정 파일을 다르게 사용하자.
- test/resources/application.yml 해당 경로에 설정파일을 따로 추가하면 된다.
- 스프링 부트는 datasource 설정이 없으면, 기본적을 메모리 DB를 사용하고, driver-class도 현재 등록된 라이브러리를 보고 찾아준다. 추가로 ddl-auto 도 create-drop 모드로 동작한다.

도메인 모델 패턴

엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것
ex) Order Entity

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
 //==연관관계 메서드==//
 	public void setMember(Member member) {
		 this.member = member;
 		member.getOrders().add(this);
	 }
	 public void addOrderItem(OrderItem orderItem) {
		 orderItems.add(orderItem);
		 orderItem.setOrder(this);
	 }
	 public void setDelivery(Delivery delivery) {
		 this.delivery = delivery;
		 delivery.setOrder(this);
	 }
	 //==생성 메서드==//
	 public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
 			Order order = new Order();
 			order.setMember(member);
			order.setDelivery(delivery);
 			for (OrderItem orderItem : orderItems) {
				 order.addOrderItem(orderItem);
			 }
 			order.setStatus(OrderStatus.ORDER);
		    order.setOrderDate(LocalDateTime.now());
		 	return order;
	}
	 //==비즈니스 로직==//
	 /** 주문 취소 */
 	public void cancel() {
 		if (delivery.getStatus() == DeliveryStatus.COMP) {
 			throw new IllegalStateException("이미 배송완료된 상품은 취소가
불가능합니다.");
 		}
 		this.setStatus(OrderStatus.CANCEL);
	 	for (OrderItem orderItem : orderItems) {
 			orderItem.cancel();
 		}
	 }
 	//==조회 로직==//
 	/** 전체 주문 가격 조회 */
 	public int getTotalPrice() {
 		int totalPrice = 0;
 		for (OrderItem orderItem : orderItems) {
 			totalPrice += orderItem.getTotalPrice();
 		}
 		return totalPrice;
 	}
}
  • setMember(),addOrderItem(), setDelivery()
    양방향 연관관계를 설정하게 되면, 연관관계 메소드를 만들어서 양쪽 객체 모두에 대하여 연관관계가 설정되도록 한다.

  • createOrder()
    이런식으로 생성자대신 정적 팩토리 메소드를 통해 객체를 생성하게 하면 다양한 장점이 있다.

정적 팩토리 메소드의 장단점

  • cancel(), getTotalPrice()
    이런식으로 도메인 내부에 비즈니스 로직 메소드를 선언하는 것이 도메인 모델 패턴이다. 도메인 모델 패턴을 사용하면 객체 지향에 기반한 재사용성,확장성, 그리고 유지 보수의 편리함 필요에 따라 약간의 수정이 필요하겠지만 언제든지 재사용할 수 있다는 장점이 있다.

동적 쿼리

OrderSearch라는 객체를 생성하여, 해당 조건에 따라서 Order를 검색하는 상황이다.

public class OrderSearch {
 private String memberName; //회원 이름
 private OrderStatus orderStatus;//주문 상태[ORDER, CANCEL]

그런데 JPQL로 쿼리를 생성하려면 매우 복잡해지고 유지보수성이 떨어지게 된다. -> 조건문으로 일일히 확인하고 문자열을 이용해 쿼리를 직접 만들어나가야 한다.

대안으로 JPA Criteria, Querydsl이 있는데 전자의 경우는 매우 복잡하다. -> Querydsl을 이용해 동적 쿼리 생성

웹 계층 개발

레이아웃

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
  <div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->

</body>
</html>

이런 식으로 레이아웃을 구성해서 중복되는 부분을 처리할 수 있다.
Thymeleaf는 이것조차 생략할 수 있는 방법을 제공하는데, 필요하면 공식 Document를 참고하도록 하자.

Vaildation

public String create(@Valid MemberForm memberForm, BindingResult result){
		if(result.hasErrors()){
			return "members/createMemberForm2";
		}
        ...
    }

@valid 어노테이션을 사용하면 해당 객체의 Validation 정보를 적용하여 검증한다.
이 어노테이션이 붙은 파라미터 뒤에, BindingResult를 받으면, 에러가 발생하여도 result에 에러를 담아 해당 컨트롤러를 실행한다.

Spring 과 Tymeleaf는 연동이 잘 돼있어서, 에러가 발생한 상태로 템플릿을 렌더링하면, 해당 에러도 같이 넘겨준다.

  • 에러가 넘어온 것을 이용하여 화면에 출력하는 코드
 <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
                   th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
            <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect date</p>

도메인과 실제 요청으로 받는 파라미터는 구성이 다르다.
엔티티를 이용해 모든 것을 처리하려 하면, 엔티티가 지저분해진다.
엔티티를 최대한 순수한 상태로 유지하는 것이 좋다.
따라서 MemberForm과 같이 따로 Dto를 만들어 값을 받는 것이 옳다.

폼 객체 vs 엔티티 직접 사용

@PostMapping(value = "/members/new")
 public String create(@Valid MemberForm form, BindingResult result) {
 	if (result.hasErrors()) {
 		return "members/createMemberForm";
 	}
 	Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());
	Member member = new Member();
 	member.setName(form.getName());
 	member.setAddress(address);
 	memberService.join(member);
 	return "redirect:/";
 }

회원가입을 하는 코드이다.
여기서 MemberForm 객체를 따로 생성하여 정보를 받고, Member객체를 생성하여 직접 설정해주는 작업을 하였다.
이렇게 하는 이유는 엔티티를 직접 사용하여 값을 받으면 문제가 많이 발생하기 때문이다.
1. 엔티티가 화면에 의존적이게 된다.
Valid같은 기능을 이용하기 위해 엔티티에 화면 처리에 필요한 코드를 추가해야 한다. -> 엔티티가 화면 종속적으로 변하고 유지보수성이 떨어진다.
2. 엔티티의 변경이 api에 영향을 끼친다.
만약 엔티티에 데이터를 추가하거나 변경, 삭제하게 되면 해당 엔티티를 이용하는 모든 api를 수정해야한다.
3. 엔티티에 대한 신뢰성이 떨어진다.
파라미터로 넘어온 엔티티에 어떤 값이 들어있는지 알 수 없다.

화면이나 API에 맞는 폼 객체나 DTO를 생성해서 사용할 것.

PathVariable

@GetMapping("items/{itemId}/edit")
	public String updateItemForm(@PathVariable("itemId") Long itemId, Model model){
		Book book = (Book)itemService.findOne(itemId);

		BookForm form = new BookForm();
		form.setId(book.getId());
		form.setName(book.getName());
		form.setPrice(book.getPrice());
		form.setStockQuantity(book.getStockQuantity());
		form.setAuthor(book.getAuthor());
		form.setIsbn(book.getIsbn());

		model.addAttribute("form",form);
		return "items/updateItemForm";
	}    
  • @PathVariable을 사용할 때는 주의해야한다.
    사용자가 임의로 다른 아이디값을 이용해 접근할 수 있기 때문이다.
    실제로 사용할 때에는 사용자가 해당 객체에 대한 접근 권한이 있는지 확인할 수 있는 로직이 필요하다.

변경감지와 병합

  • 준영속 엔티티 : 이미 한 번 DB에 저장되어서 식별자가 존재하지만, 영속성 컨텍스트가 관리하지 않는 엔티티

준영속 엔티티를 수정하는 방법은 두가지가 있다.
1. 변경감지(Dirty checking)
2. 병합(merge)

변경감지

@Transactional
void update(Item itemParam) { 
 		Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
 		findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}

위의 코드처럼 이미 가지고 있는 id값을 이용해 조회하여 영속성 컨텍스트로 만들고 수정한다.

병합

@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
 Item mergeItem = em.merge(itemParam);
}

merge를 이용하면 JPA가 파라미터로 넘긴 객체의 id값을 이용하여 해당 객체를 조회하고 조회한 객체에 파라미터로 넘긴 객체의 값으로 '모두' 대체한다.

즉, 모든 필드를 교체하므로 병합시에 값이 없으면 null로 업데이트 될 수 있는 위험이 있다.

엔티티를 변경할 때는 항상 변경 감지를 사용하자.
서비스계층에서 트랜잭션이 이뤄지므로 서비스으로 필요한 값들만 넘기고 거기서 처리하는 게 낫다 -> 특히 데이터 변경

컨트롤러에서 바로 리포지토리 호출하는 것도 고려해볼만하다.

2개의 댓글

comment-user-thumbnail
2023년 3월 9일

다이아 가야지 초심 잃었네

1개의 답글

관련 채용 정보