실전 스프링 부트와 JPA 활용1 정리

이상훈·2022년 11월 4일
0

Jpa

목록 보기
15/16

JPA와 DB 설정

main/resources/application.yml

spring:
	datasource:
		url: jdbc:h2:tcp://localhost/~/jpashop
		username: sa
		password:
		driver-class-name: org.h2.Driver

	jpa:
		hibernate:
			ddl-auto: create
		properties:
			hibernate:	
				format_sql: true

logging.level:
	org.hibernate.SQL: debug
  • format_sql
    : 보여지는 쿼리를 예쁘게 포맷팅 해준다.
  • org.hibernate.SQL : debug
    : 로그를 통해 실행 SQL을 남긴다.
  • 쿼리 파라미터 로그 남기기
    : build.gradle에 다음 코드를 추가.
    implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.5.6'

    쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하므로, 개발 단계에서는 편하게 사용해도 된다. 하지만 운영 시스템에 적용하려면 꼭 성능 테스트를 하고 사용하는 것이 좋다.


애플리케이션 아키텍처


계층형 구조 사용

  • controller, web : 웹 계층
  • service : 비즈니스 로직, 트랜잭션 처리
  • repository : JPA를 직접 사용하는 계층, 엔티티 매니저 사용
  • domain : 엔티티가 모여 있는 계층, 모든 계층에서 사용

패키지 구조

  • jpabook.jpashop
    • domain
    • exception
    • repository
    • service
    • web

📌 개발 순서: 1. 엔티티를 개발 후 2. 서비스, 리포지토리 계층을 개발하고, 3. 테스트 케이스를 작성해서 검증, 4. 마지막에 웹 계층 적용


1. 엔티티 클래스 개발

  • 모든 엔티티나 임베디드 타입은 기본 생성자를 가져야 한다
    JPA 스펙상 엔티티나 임베디드 타입은 자바 기본 생성자를 public 또는 protected로 설정해야 한다. public보다는 protected가 더 안전하므로 protected를 사용하자. @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하면 편리하게 나타낼 수 있다.

  • 엔티티에는 가급적 Setter를 사용하지 말자.
    실무에서는 Getter는 열어두고 Setter는 꼭 필요한 경우에만 사용하자. Setter를 막 열어두면 누군가 임의로 데이터를 변경할 수 있어서 유지 보수하기 힘들다. 엔티티를 변경할 때는 Setter 대신에 변경을 위한 비즈니스 메서드를 별도로 제공해야 한다.

    • 값 타입은 변경 불가능하게 설계해야 한다.
      @Setter를 제거하고 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자.

      @Embeddable 
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      public class Address {
      
      	private String city;
      	private String street;
      	private String zipcode;
      
         	public Address(String city, String street, String zipcode) {
             	this.city = city;
             	this.street = street;
             	this.zipcode = zipcode;
        	}
      }

  • 모든 연관관계는 지연 로딩으로 설정하자
    즉시 로딩은 예측이 어렵고 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다. 따라서 모든 연관관계는 지연 로딩으로 설정해야 한다.

  • 컬렉션은 필드에서 초기화하자.
    컬렉션은 필드에서 바로 초기화하는 것이 안전하다.

    @Entity
    @Getter 
    public class Member {
    
       ...
       
       @OneToMany(mappedBy = "member")
       private List<Order> orders = new ArrayList<>();
    }

  • 테이블, 컬럼명 생성 전략
    스프링 부트는 엔티티를 테이블에 매핑할 때 다음과 같은 전략을 취한다.
    • 카멜 케이스 ➡️ 언더 스코어
    • .(점) ➡️ _(언더 스코어)
    • 대문자 ➡️ 소문자

2. 서비스, 리포지토리 개발

도메인 모델 패턴

비즈니스 로직을 처리하는 두 가지 패턴이 있다.

  • 트랜잭션 스크립트 패턴 : 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 패턴이다.

  • 도메인 모델 패턴 : 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 패턴이다. 엔티티 자체가 해결할 수 있는 것은 엔티티 안에 비즈니스 로직을 넣는 것이 효율적일 수 있다. 이를 이용하는 설계 방식이 도메인 주도 설계, 흔히 DDD라 부른다.

각각의 장단점이 있으며 한 프로젝트 안에서 둘 다 사용하기도 한다. 강의에서는 DDD를 사용했다. 도메인 모델 패턴에서는 엔티티 안에 연관관계 메서드, 생성 메서드, 엔티티 전용 비즈니스 로직 등이 들어간다.

@Entity
@Table(name = "order_item")
@Getter @Setter
public class OrderItem {

	@Id @GeneratedValue
	@Column(name = "order_item_id")
	private Long id;
 
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "item_id")
	private Item item; //주문 상품
 
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "order_id")
	private Order order; //주문
 
	private int orderPrice; //주문 가격
	private int count; //주문 수량
    
    //==연관관계 메서드==//
    //이 엔티티에선 없다

	//==생성 메서드==//
	public static OrderItem createOrderItem(Item item, int orderPrice, intcount) {
		OrderItem orderItem = new OrderItem();
		orderItem.setItem(item);
		orderItem.setOrderPrice(orderPrice);
		orderItem.setCount(count);
 
 		item.removeStock(count);
		return orderItem;
	}
 
	//==비즈니스 로직==//
 	/** 주문 취소 */
	public void cancel() {
		getItem().addStock(count);
	}
 
	//==조회 로직==//
 	/** 주문상품 전체 가격 조회 */
 	public int getTotalPrice() {
 		return getOrderPrice() * getCount();	
    }
}

영속성 전이 기능 사용

앞서 Order 엔티티에 OrderItem과 Delivery 엔티티의 연관관계를 매핑할 때 Cascade로 영속성 전이 기능을 사용했다.

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
	
 	...
    
	@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
 	private List<OrderItem> orderItems = new ArrayList<>();
 
 	@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
 	@JoinColumn(name = "delivery_id")
 	private Delivery delivery; //배송정보
 
 	...
}

아래는 OrderService이다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
 
	private final MemberRepository memberRepository;
	private final OrderRepository orderRepository;
	private final ItemRepository itemRepository;
 
	/** 주문 */
	@Transactional
	public Long order(Long memberId, Long itemId, int count) {

	//엔티티 조회
	Member member = memberRepository.findOne(memberId);
	Item item = itemRepository.findOne(itemId);
 
	//배송정보 생성
	Delivery delivery = new Delivery();
	delivery.setAddress(member.getAddress());
	delivery.setStatus(DeliveryStatus.READY);
 
	//주문상품 생성
	OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

	//주문 생성
	Order order = Order.createOrder(member, delivery, orderItem);

	//주문 저장
	orderRepository.save(order);
		return order.getId();
	}

}

Order만 persist 했는데 OrderItem과 Delivery도 자동으로 persist 됨을 알 수 있다. 원래는 DeliveryRepository, OrderItemRepository 같이 리포지토리를 만들어서 각각 persist를 해줘야 한다. 이러한 영속성 전이는 아무 때나 사용하면 안 되는데 주로 한 엔티티가 특정 엔티티에 종속적인? 관계일 때 사용해야 한다. 예를 들어 이 예제에서는 Delivery, OrderItem 엔티티를 Order 엔티티에서만 조회 및 관리할 수 있어서 영속성 전이를 사용할 수 있었다. 만약 헷갈리면 사용하지 말자.


동적 쿼리

주문에서 검색 기능을 개발한다고 하자. 회원명과 주문 상태를 선택하면 그에 따라 결과값들이 나온다. 기존의 JPQl을 사용하면 다음과 같다.

public class OrderSearch {

	private String memberName; //회원 이름
	private OrderStatus orderStatus;//주문 상태[ORDER, CANCEL]
    
	//Getter, Setter
}
@Repository
public class OrderRepository {

	...
    
	public List<Order> findAll(OrderSearch orderSearch) {
    
		return em.createQuery("select o from Order o join o.member m" + 
        	" where o.status = :status " +
        	" and m.name like :name", Order.class)
        	.setParameter("status", orderSearch.getOrderStatus())
        	.setParameter("name", orderSearch.getMemberName())
        	.setMaxResults(1000)
        	.getResultList();
	}
}

하지만 문제는 회원 이름과 주문 상태를 선택하지 않을 경우이다. 즉 값이 null인 경우를 고려하면 기존 JPQL 로직으로 표현하기에는 한계가 있다. 따라서 이처럼 동적 쿼리가 필요한 경우에 Querydsl이 효과적이다.


어노테이션

  • @Transactional

    : 트랜잭션을 시작하고 마지막에 커밋. 주로 Service 계층 전체에 @Transactional(readOnly = true)를 설정하고 변경되는 메서드 쪽에 @Transactional 적용


기타

  • 생성자 주입
    : 생성자 주입 방식을 사용하자. @RequiredArgsConstructor를 사용하면 편리하다.
    @RequiredArgsConstructor
    public class MemberService {
    	private final MemberRepository memberRepository;
    	...
    }
    엔티티 매니저도 주입 가능하다.
    @Repository
    @RequiredArgsConstructor
    public class MemberRepository {
    	private final EntityManager em;
    	...
    }

3. 테스트

테스트 코드를 위한 설정

테스트는 케이스는 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다. 그런 면에서 메모리 DB를 사용하는 것이 가장 이상적이다. 추가로 테스트 케이스를 위한 스프링 환경과, 일반적으로 애플리케이션을 실행하는 환경은 보통 다르므로 설정 파일을 다르게 사용하자. 다음과 같이 간단하게 테스트용 설정 파일을 추가하면 된다.

test/resources/application.yml

spring:
# datasource:
# 	url: jdbc:h2:mem:testdb		//메모리 DB를 지정해줌
# 	username: sa
# 	password:
# 	driver-class-name: org.h2.Driver

# jpa:
# 	hibernate:
# 		ddl-auto: create-drop
# 	properties:
# 		hibernate:
# 			show_sql: true
# 			format_sql: true
# 	open-in-view: false

logging.level:
	org.hibernate.SQL: debug
#	org.hibernate.type: trace

원래는 위와 같은 설정 코드가 필요하지만, 스프링 부트는 datasource, JPA 관련 설정을 알아서 해준다. 따라서 application.yml 파일만 만들어두자. 이제부터는 데이터베이스를 꺼도 테스트를 실행할 수 있다.


어노테이션

  • @Transactional
    : 트랜잭션을 시작하고 마지막에 롤백. 만약 DB로 확인하고 싶으면 @Rollback(false) 적용.
  • @Test(expected = XXXExveption.class)
    : 테스트 안에서 지정한 XXX 예외가 터지면 True.
  • @RunWith(SpringRunner.class)
    : 스프링과 테스트 통합.
  • @SpringBootTest
    : 스프링 부트 띄우고 테스트(이게 없으면 @Autowired 다 실패).

4. 웹 계층 개발

폼 객체 생성

Member 엔티티가 아닌 MemberForm 객체를 따로 생성해 MemberController에서 사용했다.

@Controller
@RequiredArgsConstructor
public class MemberController {

	private final MemberService memberService;

	@GetMapping(value = "/members/new")
	public String createForm(Model model) {
		model.addAttribute("memberForm", new MemberForm());
		return "members/createMemberForm";
	}

	@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이다.

@Getter @Setter
public class MemberForm {

    @NotEmpty(message = "회원 이름은 필수 입니다")
    private String name;
    private String city;
    private String street;
    private String zipcode;
}

Member 엔티티와 거의 비슷해 보이는데 따로 이렇게 폼 객체를 생성한 이유는 무엇일까?

Member 엔티티에는 id, name, address, orders 등 다양한 필드들이 존재한다. 만약 엔티티를 화면을 처리하는 용도로 그대로 사용한다면 엔티티와 화면 쪽에서 서로 필요한 필드들이 다르므로 억지로 맞춰야 한다. 또한 validation을 사용하면 그에 따른 어노테이션도 추가해야 하므로 화면에 종속적인 기능들이 추가되어 엔티티가 지저분해진다. 따라서 그냥 깔끔하게 필요한 필드들을 가지는 Form을 생성했다. 정말 간단한 애플리케이션에선 Member 엔티티 자체를 사용할 수도 있으나 실무는 그런 경우가 거의 없다. 엔티티는 핵심 비즈니스 로직에만 집중하고 화면을 처리하는 것은 Form이나 Dto를 사용하는 것이 좋다.

참고로 Form 객체는 Controller 계층에서만 사용하므로 Controller 패키지에 두는 것이 좋다.


권한

아래와 같은 코드에서는 누군가 ID를 조작해 접근할 수 있어서 보안에 취약하다. 따라서 이 유저가 이 아이템에 대해 권한이 있는지 없는지 체크하는 로직이 필요하다.

@Controller
@RequiredArgsConstructor
public class ItemController {

	/**
 	* 상품 수정 폼
 	*/
	@GetMapping(value = "/items/{itemId}/edit")
	public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {
	
    	Book item = (Book) itemService.findOne(itemId);
 	
 	    ...
	}
 
 	/**
 	* 상품 수정
 	*/
	@PostMapping(value = "/items/{itemId}/edit")
	public String updateItem(@ModelAttribute("form") BookForm form) { 
    	...
	}
}

변경감지와 병합

엔티티의 필드 값들을 수정할 때 주의해야 한다. 굉장히 중요한 개념이니 100프로 이해하자. 정상적으로 동작하므로 별문제 없어 보이지만 상당히 위험한 로직이다.

먼저 아래 코드들을 보자.

@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
	
    Book book = new Book();
	book.setId(form.getId());
	book.setName(form.getName());
    book.setPrice(form.getPrice());
	book.setStockQuantity(form.getStockQuantity());
	book.setAuthor(form.getAuthor());
	book.setIsbn(form.getIsbn());
	
    itemService.saveItem(book);
	
    return "redirect:/items";
}

@Service
@RequiredArgsConstructor
public class ItemService {
 
	private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        itemRepository.save(item);
    }

}

@Repository
public class ItemRepository {

	@PersistenceContext
	EntityManager em;
 
	public void save(Item item) {
		if (item.getId() == null) {
			em.persist(item);
		} else {
			em.merge(item);
		}
	}
	//...
}

위에서는 Book 객체처럼 임의로 만들어낸 엔티티가 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다. Book 객체는 JPA, 즉 영속성 컨텍스트가 관리하지 않으며 따라서 변경 감지 기능이 동작하지 않는다.

이러한 준영속 엔티티를 수정하는 2가지 방법이 있다.

  • 병합 사용
    위 코드처럼 merge()를 사용하는 방식이다. 이 기능을 사용하면 모든 속성이 변경된다. 병합은 모든 필드를 교체하므로 값이 없으면 null로 업데이트할 위험이 있어서 매우 위험하다. 따라서 실무에서는 사용하지 않는다.

  • 변경 감지 기능 사용
    준영속 엔티티이지만 변경 감지 기능을 사용할 수 있도록 하면 된다. 트랜잭션 안에서 엔티티를 다시 조회한 후 영속성 컨텍스트 안에서 데이터를 변경하여 변경 감지 기능을 사용하는 방식이다.

따라서 아래와 같이 리팩토링 하는 것을 강력 추천한다.

@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@ModelAttribute("form") BookForm form) {
	itemService.updateItem(form.getId(), form.getName(), form.getPrice());
	return "redirect:/items";
 }

@Service
@RequiredArgsConstructor
public class ItemService {

	private final ItemRepository itemRepository;

 	/**
 	* 영속성 컨텍스트가 자동 변경
 	*/
	@Transactional
	public void updateItem(Long id, String name, int price) {
		Item item = itemRepository.findOne(id);
		item.setName(name);
		item.setPrice(price);
	}
}

여기서는 setter를 사용했지만 의미있는 메서드를 만들자.


기타

  • 뷰 템플릿 변경사항을 서버 재시작 없이 즉시 반영하기.
    : 단순히 웹 페이지에서 새로고침만으로 가능.
    1. spring-boot-devtools 추가
    2. html 파일 build-> Recompile
profile
Problem Solving과 기술적 의사결정을 중요시합니다.

0개의 댓글