"실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발" 나의 정리
내가 사용하는 프레임워크를 제대로 이해하고 싶어서
김영한님의 스프링 부트 강의를 듣고 있습니다
"스프링 입문"은 필요한 부분 위주로 보았고
"스프링 핵심원리 - 기본편"은 좋은 객체 지향 애플리케이션을 개발하기 위해서 스프링이 어떻게 설계되어 있는 지 자세하게 알려주십니다
다형성과 객체지향설계에 따라서 개발을 하다보면 제어흐름을 AppConfig 쪽으로 넣고 스프링 부트에서는 이것을 DI 컨테이너 (스프링 컨테이너, 컨테이너)에서 관리합니다. 스프링 컨테이너가 관리하는 빈들의 스코프(싱글톤, 프로토타입), 의존관계 주입, 라이프사이클 등등에 대해 배울 수 있습니다.
핵심원리는 정말 많은 도움이 되었고 노션에 정리하면서 들었는데 나중에 다시 따로 정리할 기회가 있으면 올릴 예정입니다
영한님의 강의를 다 듣고 기존에 개발했던 토이프로젝트를 업데이트하는 것까지가 목표입니다 (서버를 운영하면서 느꼈던 안티패턴들을 고치려고 합니다)
실전 스프링 부트와 JPA 활용1편은 "유저가 Item(책, 앨범, 영화 등 여기에서는 책으로 대표)을 구입"하는 것을 만들어보면서 스프링 부트와 JPA를 제대로 사용해보는 것에 초점이 맞추어져 있습니다
다음의 것들을 해볼 수 있었습니다
- Entity 설계
- H2 데이터베이스 설치, 설정
- JPA 기능을 제대로 사용: 연관관계 설정하기, SINGLE_TABLE 사용, Embedded 타입 사용, CASCADE 사용, LAZY loading 사용하기, merge 사용하지 말고 변경감지(Dirty Checking) 사용하기 등
- 도메인 모델 패턴
- (가볍게 볼 수 있었던) 예외처리: NotEnoughStockException
스프링 개발에 꼭 필요하신 지식들과 탑클래스에 계신 분의 실무에서 개발하시는 패턴을 볼 수 있어 강의를 보시는 것을 추천드립니다
여기에서는 제가 새로 알게되었거나 꼭 기억하고 싶은 것들 위주로 적을 생각입니다
## 1. 도메인 모델 패턴
@Entity
@Table(name = "orders")
@Getter @Setter
// 밑에 선언된 생성 메서드로만 Order를 만드는 것을 분명히 하기 위해서
// 디폴트 생성자는 PROTECTED로 변경
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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;
}
}
Entity에 로직을 넣어서 개발하는 패턴이 서비스나 다른 곳에서 개발할 때 객체지향적으로 개발할 수 있어서 많이 배웠다.
기존에는 Transaction Script 패턴에 가깝게 개발해서 로직을 개발할 때 일련의 관련된 로직들을 신경쓰면서 잊지 않고 넣어야 했는데 도메인 모델 패턴이 이런 점에서 확실히 좋다고 생각이 들었다 (객체지향적, 코드 응집)
영한님께서 두 패턴이 장단이 있어서 잘 맞는 상황이 있다고 하셨다
## 2. SINGLE_TABLE
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
...속성들과 도메인 코드들...
}
@Inheritacne로 single table 패턴을 쉽게 만들 수 있다
@DiscriminatorColumn에 dtype 같은 칼럼명을 명시하면 된다
@Entity
@DiscriminatorValue("B")
@Getter @Setter
public class Book extends Item {
private String author;
private String isbn;
}
상속받아서 구현하는 쪽에서
@DiscriminatorValue에 "dtype" 칼럼에 들어갈 값을 설정하면 된다
## 3. 임배디드 타입
// 값 타입은 immutable 해야하므로 @Setter 를 만들지 않는다
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
// 기본 생성자를 사용하지 않는다라는 개발자의 의도를 분명히 드러냄
protected Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
그리고 사용하는 쪽에서
@Embeded 어노테이션을 넣어주면 된다
@Entity
@Getter @Setter
public class Member {
...
@Embedded
private Address address;
...
}
## 4. 연관관계 설정하기 (OneToMany, ManyToMany..)
연관관계의 주인은 @JoinColumn을 통해서 column을 가지고
읽기전용 속성은 mappedBy를 통해서 연결을 해 거울이 된다
4-1 ManyToOne, OneToMany
@Entity
// sql 예약어 order와 충돌나지 않게 orders로 변경
@Table(name = "orders")
public class Order {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
...
}
@Entity
@Getter @Setter
public class Member {
// Member.orders를 변경해도 Order의 fk가 변경되지 않는다
// 읽기전용, member 필드에 의해서 거울이 되는 거다
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@JoinColumn의 값은 데이터베이스에 들어갈 fk의 칼럼 이름이다
mappedBy의 값은 미러링하고 있는 객체의 속성값을 적으면 된다
이 경우에는 Order 객체의 member 필드를 미러링하고 있어서 "member"를 적는다
4-2 OneToOne
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
// 1:1 관계에서 foreign key를 누가가지는 지 선택할 때
// 경험적으로 누구를 통해서 접근을 많이하는 지가 중요하다
// Order를 통해서 Delivery를 접근하는 일이 많으니까
// fk를 Order에 둔다
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id") // 연관관계의 주인한테 JoinColumn 쓴다
private Delivery delivery;
}
@Entity
@Getter @Setter
public class Delivery {
// Order.delivery 의 거울이다
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
}
마찬가지로 연관관계의 주인에게 @JoinColumn을 붙이고 값으로 column명을 적는다
그리고 연관관계의 거울에게 mappedBy 속성을 적어주고 그 값으로 미러링하고 있는 객체의 필드명을 적으면 된다
OneToOne의 경우 연관관계의 주인은 어떤 객체를 통해서 접근을 많이 하는 지가 중요하다
이 경우에는 Order를 통해서 Delivery를 접근하는 경우가 많으니까 Order가 연관관계의 주인으로 설정하였다
## 5. LAZY 설정하기
N+1 문제와 예측의 어려움으로 EAGER는 쓰지 않는다
fetch join으로 N+1 문제를 해결한다
@xxxToMany는 fetch 기본 설정이 LAZY이다 그래서 따로 설정해주지 않아도 되는데
@xxxToOne은 fetch 기본 설정이 EAGER이다. 그래서 LAZY로 설정해야한다
## 6. Cascade 설정하기
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
// cascade 같은 경우에는 private owner 인 경우에 사용해야한다
// 두개의 엔터티의 라이프 사이클이 동일한 경우에 사용
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
}
orderItems에 CascadeType.ALL이 설정되어 있어서
orderItems와 함께 생성되고 함께 삭제된다
그래서 라이프 사이클이 동일한 경우에만 적절히 잘 사용해야한다
## 7. merge 사용하지 말고 변경감지(Dirty Checking) 사용하기
준영속상태: 데이터베이스에 저장되어 있지만 영속성 컨텍스트에서 관리하지 않는 상태
준영속상태에 있는 객체를 업데이트하는 것이 em.merge()이다
엔터티 변경할 때 넣지 않은 파라미터 값들을 전부 null로 처리해서 (PUT이랑 비슷하다고 생각했다) 유지 보수가 어렵다
그래서 변경감지로 준영속 엔터티를 업데이트해야 한다
변경감지를 통해서 특정 파라미터들을 변경시키면 @Transaction 이 commit()할 때 영속성 컨텍스트가 flush를 통해서 변경감지(Dirty Checking)가 작동하면서 변경이 일어난 부분을 자동으로 update query 만들어서 실행시켜준다
그리고 변경할 때 setter 사용하지 말고 꼭 메소드 만들어서 하는 게 유지관리 측면에서 좋다
@PostMapping("/items/{itemId}/edit")
public String updateItem(
@ModelAttribute("form") BookForm form,
@PathVariable String itemId
) {
itemService.updateItem(form.getId(), form.getName(), form.getPrice());
return "redirect:/items";
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
// Entity 변경은 꼭 변경감지 (Dirty Checking)을 통해서 하자
public void updateItem(Long id, String name, int price) {
Item item = itemRepository.findOne(id);
item.setName(name);
item.setPrice(price);
}
}
## 8. 그 외
@Transactional(readOnly = true)을 선언하자
JPA는 tx내에서 영속성 컨텍스트가 따라다니니까
@Transactional을 선언하자
readOnly를 하면 최적화에 도움이 된다
1. 변경감지(Dirty Checking)을 안한다
2. Driver에 따라서 데이터베이스에 최적화를 해줄 수도 있다