Open seungriyou opened 9 months ago
h2 데이터베이스 설치 완료 가정
h2.sh
가 위치한 곳으로 이동하고 실행한다.
```bash
cd workspace/spring-study/2024/h2/bin
./h2.sh
```
열린 브라우저에서 url의 IP 주소 부분을 localhost로 변경한다.
JDBC URL을 db 파일 생성을 원하는 디렉토리 경로로 설정한다. (파일 모드)
맨 마지막 부분은 db 파일의 이름이어야 함에 주의한다!
jdbc:h2:~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop
좌측 상단에 연결 끊기 버튼을 누른후, JDBC URL에 tcp://localhost/
를 삽입해준다. (네트워크 모드)
jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop
application.properties
파일을 삭제하고application.yml
파일을 생성한다.
spring.jpa.hibernate.ddl-auto: create
: 애플리케이션 시점에 테이블을 drop 하고 다시 생성한다.- 모든 로그 출력은 가급적 logger를 통해 남겨야 한다.
옵션 | SQL 출력 위치 |
---|---|
show_sql |
하이버네이트 실행 SQL을 System.out에 남긴다. (권장 X) |
org.hibernate.SQL |
하이버네이트 실행 SQL을 logger를 통해 남긴다. |
- https://twer.tistory.com/entry/JUnit5-RunWith - https://kangyb.tistory.com/37 - https://github.com/seungriyou/spring-study/issues/4#issuecomment-1877308690
JUnit5 + Spring Boot 3.X 기준으로, 강의에서 나오는 @RunWith(SpringRunner.class)
를 사용할 수 없다.
찾아보니 JUnit5에서부터는 @ExtendWIth(SpringExtension.class)
를 사용해야 하는데, 이 버전부터는 통합 테스트 시 사용했던 @SpringBootTest
만 사용해도 해당 기능이 모두 포함되어 있기 때문에 사용하지 않아도 된다고 한다.
@SpringBootTest
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
@Transactional
public void testMember() throws Exception { ... }
}
EntityManager
를 통한 모든 데이터 변경은 항상 트랜잭션 안에서 이루어져야 한다. 여기에서는 우선 리포지토리가 아닌 테스트 케이스에 트랜잭션을 걸어주자.
테스트 케이스가 실행된 후 바로 롤백한다. 롤백을 하고 싶지 않다면, 테스트케이스에
@Rollback(false)
를 걸어준다.
또한, @Transactional
어노테이션은 스프링에서도 제공하고 자바 표준에서도 지원하는데, 현재 스프링에 종속적으로 개발하고 있으므로 스프링에서 제공하는 기능을 사용하자.
다양한 옵션을 제공하기 때문이다.
[!Caution] 같은 영속성 컨텍스트 안에서는 id 값이 같으면 같은 엔티티로 식별된다. 1차 캐시에서 이미 영속성 컨텍스트에 해당 엔티티가 관리되고 있기 때문이다.
findMember = jpabook.jpashop.Member@193c810 member = jpabook.jpashop.Member@193c810 (findMember == member) = true
다음의 명령어로 빌드한다.
./gradlew clean build
build/libs
디렉토리로 이동한다.
cd build/libs
jar 파일을 실행한다.
java -jar jpashop-0.0.1-SNAPSHOT.jar
현재는 org.hibernate.SQL
로 출력된 로그에 다음과 같이 쿼리 파라미터가 나타나지 않는다.
insert
into
member
(username, id)
values
(?, ?)
우선, application.yml
파일에 다음과 같이 org.hibernate.orm.jdbc.bind: trace
를 추가한다.
logging:
level:
org.hibernate.SQL: debug # logger로 sql 찍기
org.hibernate.orm.jdbc.bind: trace # 스프링 부트 3.x, hibernate6
그렇다면 다음과 같이 binding 결과가 출력된다.
2024-01-14T19:22:46.856+09:00 TRACE 29683 --- [ Test worker] org.hibernate.orm.jdbc.bind : binding parameter (1:VARCHAR) <- [memberA]
2024-01-14T19:22:46.857+09:00 TRACE 29683 --- [ Test worker] org.hibernate.orm.jdbc.bind : binding parameter (2:BIGINT) <- [1]
그리고, 스프링 부트를 사용한다면 다음의 라이브러리를 추가하면 더 직관적으로 확인할 수 있다.
외부 라이브러리: https://github.com/gavlyukovskiy/spring-boot-data-source-decorator
→ 여기에서 설치 방법을 참고한다. (스프링 부트 3.0 이상에서는 1.9.0 이상을 사용해야 한다.)
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'
해당 라이브러리에서는 버전 정보를 명시하는 이유는, 외부 라이브러리 버전에 대해서는 스프링 부트에서 미리 셋팅을 해두지 않았기 때문이다.
2024-01-14T19:28:06.250+09:00 INFO 29849 --- [ Test worker] p6spy : #1705228086250 | took 1ms | statement | connection 4| url jdbc:h2:tcp://localhost/~/workspace/spring-study/spring-study/04-jpa_1_app/jpashop/jpashop
insert into member (username,id) values (?,?)
insert into member (username,id) values ('memberA',1);
[!Caution] 쿼리 파라미터를 로그로 남기는 외부 라이브러리는 시스템 자원을 사용하기 때문에, 개발 단계에서는 사용해도 되지만 운영 시스템에 적용하려면 꼭 성능 테스트를 하고 사용해야 한다.
사실 실무에서는 잘 넣지 않는 요소도 포함되어 있다.
- 다대다 관계 (
Category
)- 양방향 관계 (
Member
,Order
) : 실제 세계와 객체 세계는 다르다. 실무에서는 회원이 주문을 참조하지 않고, 주문이 회원을 참조하는 것으로 충분하기 때문에 양방향일 필요가 없다.
[!note] 실습 코드에서는 DB에 소문자 +
_
스타일을 사용하겠다!
Member
과 Order
: 다대일/일대다 양방향 → 연관관계 주인은 외래키가 존재하는 다 쪽인 Order
여야 한다.OrderItem
과 Order
: 다대일 양방향 → 연관관계 주인은 외래키가 존재하는 OrderItem
이다.OrderItem
과 Item
: 다대일 단방향 → 연관관계의 주인은 외래키가 존재하는 OrderItem
이다.Order
와 Delivery
: 일대일 양방향 → 주 테이블에 FK 두는 것으로 선택Category
와 Item
: @ManyToMany
를 이용해서 매핑 (실무에서는 사용 X)[!tip] 외래키가 있는 곳을 연관관계의 주인으로 정하자! 비즈니스상 우위에 있다고 주인으로 정하면 안 된다.
이렇게 해야 해당 테이블을 변경할 수 있어 더 편리하다.
테이블은 FK에서만 변경하면 되는데, 객체에서는 양쪽에서 모두 가능하므로, 어느 곳을 믿고 update를 쳐야하는지?
→ JPA에서는 연관관계의 주인 쪽을 믿고 update를 쳐야한다고 정해두었다.
[!tip] 여기에서는 쉽게 설명하기 위해 엔티티 클래스의 getter와 setter를 모두 열어두고 단순하게 설계한다. 하지만 실무에서는 가급적 getter는 열어두고 setter는 꼭 필요한 경우에만 사용해야 한다.
이론적으로는 getter/setter를 모두 제공하지 않고, 필요한 별도의 메서드를 제공하는 것이 가장 이상적이다. 하지만 실무에서 엔티티의 데이터는 조회할 일이 너무 많기 때문에 getter의 경우엔 모두 열어두는 것이 편리하다. 반면, setter를 열어두면 엔티티의 변경을 추적하기 어려우므로, 엔티티를 변경할 때는 변경 지점이 명확하도록 비즈니스 메서드를 별도로 제공해야 한다.
엔티티의 id
필드에 column 이름을 table명+id
로 사용하는 것은, 테이블에는 타입이 없으므로 구분이 어렵기 때문이다. 중요한 것은 일관성이다!
실무에서는 @ManyToMany
를 사용하지 말자. (다른 정보를 넣을 수도 없고 운영도 어렵다.)
값 타입은 불변객체로 만들어야 한다. 즉, 생성 시에만 데이터를 주입받고, getter만 제공해야 한다.
JPA 스펙 상 엔티티나 임베디드 타입(@Embeddable
) 은 자바 기본 생성자를 public
또는 protected
로 설정해야 한다. protected
로 설정하는 것이 그나마 더 안전하다. (기본 생성자를 막 호출하면 안 되겠구나~를 의미) 이러한 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플렉션, 프록시 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.
<설계 → 돌려서 테이블 생성된 것 확인> 반복하면서 개발하면 된다.
출력된 SQL 문은 그대로 사용하면 안 되고, 수정해서 DDL 스크립트로 사용하는 것은 가능하다.
[!note] FK를 꼭 걸어야하는가? (JPA은 alter SQL을 통해 FK를 걸어줌)
- 실시간 트래픽이 중요하고, 정합성보다는 유연하게 잘 서비스되는 것이 중요하다면 인덱스만 잘 잡아도 OK
- 너무 중요하고 데이터가 항상 맞아야 한다면 FK 걸어주는 것이 GOOD
엔티티에는 가급적 setter를 사용하지 말자.
모든 연관관계는 지연로딩으로 설정해야 한다.
**@XToOne
관계**는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.컬렉션은 필드에서 초기화하자.
@OneToMany(mappedBy = "order")
private List<OrderItem> orderItems = new ArrayList<>();
em.persist()
)할 때, 컬렉션을 감싸서 하이버네이트 제공 내장 컬렉션으로 변경한다. 만약 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 매커니즘에 문제가 발생할 수 있다.테이블, 컬럼명 생성 전략
스프링 부트에서는 하이버네이트 기본 매핑 전략(= 엔티티 필드명을 그대로 테이블의 컬럼명으로 사용)을 변경해서 실제 테이블 필드명은 달라지게 된다. 엔티티 (필드) | 테이블 (컬럼) |
---|---|
카멜 케이스 | 언더스코어 |
. |
_ |
대문자 | 소문자 |
적용 2단계 (논리명 생성, 물리명 적용)
CASCADE
ALL
: persist를 전파한다.Order
의 orderItem
, delivery
에 cascade를 적용한다.
Order
를 영속화할 때,orderItem
과delivery
가 모두 영속화된다.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
// order만 persist 하면 된다.
// em.persist(orderItemA);
// em.persist(orderItemB);
// em.persist(orderItemC);
em.persist(order);
양방향 관계일 때는 원자적으로 양쪽에서 값을 저장해주는 것이 좋다. 이때, 연관관계 편의 메서드를 생성한다.
🔉 양방향 관계일 때, 연관관계 편의 메서드는 핵심적으로 컨트롤 하는 쪽에 위치하는 것이 좋다.
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);
}
Category
// === 연관관계 편의 메서드 (양방향) ===
public void addChildCategory(Category child) {
this.child.add(child);
child.setParent(this);
}
[!caution] 이번 강의에서 했던 내용은 다음의 코드에서 확인할 수 있다!
중요한 내용 잊지 않도록 복기하자!!
EntityManager
스프링이 EntityManager
를 만들어서 주입해주므로, @PersistenceContext
로 필드 주입 받는다.
@PersistenceContext
private EntityManager em; // 스프링이 EntityManager를 만들어서 주입해 줌
혹은 다른 컴포넌트와 동일하게 의존관계 주입을 받아도 된다. (ex. final
& @RequiredArgsConstructor
) 이는 스프링 부트(스프링 데이터 JPA?)에서 지원해주는 기능이다. (✅)
@Repository // 스프링 빈 등록 (컴포넌트 스캔)
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em; // 다른 컴포넌트와 동일하게 생성자 주입 가능
JPQL
id
값 가져오기em.persist(member)
를 하면, 영속성 컨텍스트에서 member
가 관리된다.
이때, 영속성 컨텍스트는 객체를 key와 value로 관리하게 되는데, key 값은 id
가 된다. (PK와 매핑한 것이 key가 되기 때문)
따라서 영속성 컨텍스트에 올라갔다면, 아직 DB에 저장된 시점이 아니더라도 **id
값이 생성되었다는 것이 보장**된다.
@Transactional
@Transactional
을 클래스 레벨에서 적용하면, 내부의 public
메서드들은 모두 적용된다.@Transactional
은 스프링에서 제공하는 것을 사용하자.@Transactional(readOnly = true)
로 설정하면, 읽기 전용(조회) 곳에서는 성능 최적화를 해준다.@Transactional(readOnly = true)
로@Transactional
달기 (default readOnly = false
)검증 로직의 경우, 멀티 스레드 상황을 고려하면 동시에 실행될 수 있어 통과될 수 있다.
실무에서는 이러한 경우를 방어하기 위해 데이터베이스에 Member
의 name
을 unique 제약 조건으로 걸어주어야 한다.
필드 주입, 수정자 주입보다 생성자 주입을 사용하자. final
과 lombok의 @RequiredArgsContstructor
를 이용하면 생성자를 생성할 필요가 없다.
@Transactional
(
회원가입()
) 테스트에서@Transactional
을 사용하면 insert 쿼리가 나가지 않는다.
- 원래는 commit 시에 sql이 나가야 하는데, 테스트에서
@Transactional
을 사용하면 commit 되지 않고 rollback 되기 때문이다.- 테스트 케이스에
@Rollback(false)
로 rollback을 끌 수도 있으나, 권장하지 않는다. (실제 DB에 적용되므로)- insert 쿼리를 보고 싶다면,
EntityManager
도 주입 받아서em.flush()
를 해서 콘솔에 sql이 찍히는 것을 보면 된다.sql이 나갔음에도 불구하고 트랜잭션이기 때문에 롤백된다.
try-catch 문 & fail()
사용하기
fail()
라인에 도달하면 테스트 실패
Assertions.assertThrows(Exception.class, () -> ...)
사용하기테스트 시에만 스프링 내에 임시 데이터베이스 띄우는 방법
test/resources
디렉토리를 만들고, 그곳에main/resources/application.yml
파일을 복사 붙여넣기 한다.테스트 코드의 경우, test 디렉토리에 있는 resources를 우선적으로 사용한다.
spring.datasource.url
을 다음과 같이 바꾼다.JVM 안에서 띄울 수 있다. 로컬에서 h2 데이터베이스를 띄울 필요가 없다!
```yaml
spring:
datasource:
url: jdbc:h2:mem:test
```
혹은, application.yml
에서 spring.datasource
, spring.jpa
관련 설정을 모두 지워도 가능하다. 스프링에서 기본으로 메모리 모드를 지원해주기 때문이다.
또한, 스프링은 기본적으로 create drop
모드를 지원한다.
[!tip] 개발 상황과 테스트 상황에서의
application.yml
등의 설정은 따로 가져가는 것을 권장한다.
엔티티 안에 비즈니스 로직을 넣어보자! (응집도)
객체지향적으로 생각해보면, 데이터를 가지고 있는 쪽에(→ 즉, 엔티티에!) 비즈니스 로직이 있는 것이 좋다.
안 그러면 엔티티에서 값을 꺼내와서 밖에서 계산하고 다시 설정해야 하는데, 실무에서는 setter 사용을 지양해야 한다.
따라서, 엔티티의 값을 변경(생성 X)해야 할 일이 있으면 핵심 비즈니스 로직을 통해서 해야 한다.
// === 비즈니스 로직 ===
/*
재고 증가
*/
public void addStock(int quantity) {
this.stockQuantity += quantity;
}
/*
재고 감소
*/
public void removeStock(int quantity) {
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new NotEnoughStockException("need more stack :(");
}
this.stockQuantity = restStock;
}
em.merge()
에 대해서는 추후 다시 다룬다!
@Repository
@RequiredArgsConstructor
public class ItemRepository {
private final EntityManager em;
public void save(Item item) {
if (item.getId() == null) { // (1) item이 영속화 되기 전의 새롭게 생성한 객체라면
em.persist(item); // 신규 등록 (persist)
} else { // (2) 이미 DB에 등록된 걸 가져온 것이라면
em.merge(item); // merge (update와 비슷한 동작)
}
}
public Item findOne(Long id) {
return em.find(Item.class, id);
}
public List<Item> findAll() {
return em.createQuery("select i from Item i", Item.class)
.getResultList();
}
}
@Transactional(readOnly)
에 주의하자!@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
@Transactional // override (readOnly = false)
public void saveItem(Item item) {
itemRepository.save(item);
}
public List<Item> findItems() {
return itemRepository.findAll();
}
public Item findOne(Long itemId) {
return itemRepository.findOne(itemId);
}
}
[!note] 엔티티에 실제 비즈니스 로직이 있어서 더 많은 것을 엔티티로 위임하는 도메인 모델 패턴에 대해 알아보자!
[!tip]
this
를 꼭 써야 할까?꼭 필요한 것이 아니라면 안 써도 충분히 IDE 하이라이팅으로 구별할 수 있기 때문에 안 써도 된다.
static
으로 구현하며, 해당 엔티티는 이 메서드로만 생성해야 한다.OrderItem
// === 생성 메서드 ===
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
// 할인 기능이 추가될 수 있으므로 orderPrice는 따로
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
// Item 재고 감소
item.removeStock(count);
return orderItem;
}
// === 비즈니스 로직 ===
public void cancel() {
// Item의 재고를 주문 수량만 추가해주어야 한다.
getItem().addStock(count);
}
// === 조회 로직 ===
/*
주문 상품 가격 조회
*/
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
Order
// === 생성 메서드 ===
// 생성하는 지점을 변경해야 한다면 여기만 바꾸면 된다!
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);
// Item의 재고를 원상복구하기 위해
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
// === 조회 로직 ===
/*
전체 주문 가격 조회
*/
public int getTotalPrice() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
원래는 order
를 persist 하려면 생성한 delivery
도, orderItem
도 persist 해주는 동작을 각각의 리포지토리를 이용해 수행해야 한다. (ex. save()
)
하지만 그렇게 하지 않고, order
만 save()
하면 되는데, 이는 다음과 같은 cascade 옵션 때문이다.
// cascade
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
// cascade
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
따라서 order
만 영속화해도 delivery
와 orderItem
도 함께 영속화된다.
[!note]
어디까지cascade
범위를 설정해야 할까?
다른 것이 참조할 수 없는 프라이빗 오너인 경우
delivery
와orderItem
모두 속하는order
에서만 사용한다.- persist 해야 하는 라이프 사이클이 같은 경우
딱 이 정도 범위에서만 써야 한다.
만약 다른 곳에서도
delivery
를 가져다 쓰는 등, 위 조건에 해당하지 않는다면, cascade 설정하면 안 되고 별도의 리포지토리를 생성해서 따로 persist 해야 한다.
엔티티 객체를 생성할 때 생성 메서드(ex. OrderItem.createOrderItem()
) 사용을 강제하려면, 해당 엔티티의 생성자를 protected
로 막아두어야 한다.
JPA에서
protected
는 바깥에서 쓰지 말라는 것이다!
// 생성 메서드 사용을 강제하기 위해 기본 생성자 + protected
protected OrderItem() {
}
혹은, lombok에서 제공하는 @NoArgsConstructor(access = ...)
를 이용해도 된다. (✅)
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
[!tip]
항상 코드를 제약하는 스타일로 짜야 좋은 설계와 유지보수로 다가갈 수 있다.
SQL을 직접 사용하는 경우(ex. MyBatis, JdbcTemplate 등)에는 서비스 계층에서 비즈니스 로직을 쓸 수 밖에 없다.
어떤 엔티티의 비즈니스 로직 내에서 다른 엔티티들을 수정한 경우, 해당 변경 사항들을 DB에 반영하기 위해 결국 서비스 계층에 전부 꺼내와서 직접 DB에 반영해야 하는 것이다. (update 쿼리)
하지만 JPA를 사용한다면, JPA가 알아서 dirty checking을 통해 변경된 내역들에 대해 자동으로 update 쿼리를 날린다.
JPA의 매우 큰 장점이다!
[!note] 이처럼 비즈니스 로직 대부분이 엔티티에 있어 객체지향의 특성을 적극 활용하여, 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 하는 것을 도메인 모델 패턴이라 한다.
JPA와 같은 ORM을 사용하면 이러한 방식으로 개발하게 된다!
그와 반대로 엔티티에는 비즈니스 로직이 거의 없고, 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.
어떤 패턴이 유지보수가 더 쉬운지를 판단해야 한다. 또한, 한 프로젝트 안에서도 양립하기도 한다.
[!tip] 사실 좋은 테스트란 스프링과 상관 없이, dependency 없이 해당 메서드가 동작하는 것을 확인하는 단위 테스트이다. (ex.
Item
엔티티 내의removeStock()
에 대한 단위 테스트)지금은 스프링, JPA와 엮어서 통합 테스트를 하고 있다. 여러 엔티티, 여러 기능을 섞어서 테스트 하는 경우이다.
[!note] 생산성을 높이기 위한 프로젝트 셋팅 → 스프링 부트, 스프링 데이터 JPA, QueryDSL
@Valid
기능을 사용할 수 있다.
MemberForm
@Getter
@Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수입니다.")
private String name;
MemberController
@PostMapping("/members/new")
public String create(@Valid MemberForm form) {
왜 Member
엔티티가 아닌 MemberForm
을 사용할까?
[!tip] 등록 / 수정 시 폼 객체를 써야하나 엔티티를 써야하나?
- 요구 사항이 매우 단순하면 엔티티를 사용해도 된다. 하지만 엔티티를 폼으로 사용하게 된다면 화면을 처리하기 위한 기능이 점점 증가되어, 결과적으로 엔티티에 화면 종속 기능이 계속 생기게 된다. 따라서 유지 보수가 어려워지게 된다.
- 엔티티를 최대한 순수하게, 최대한 의존성이 없도록 만들어야 한다. 즉, 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다.
- 화면에 맞는 API는 폼 객체나 DTO를 사용하는 것을 권장한다.
[!caution] API를 만들 때에는 절대 엔티티를 외부로 넘기면 안 된다!!
엔티티에 로직을 추가하면 API 스펙이 바뀌어 버리게 된다.
[!warning] 컨트롤러에서
book
을 생성할 때 setter를 사용하기보다는,createBook
과 같은 static 생성 메서드를 사용하는 것을 권장한다. (예제에서는 setter를 열어두었다)
[!note] 수정하는 방법에는 두 가지가 있다.
- 변경 감지 (→ JPA의 best practice)
- 병합 (→ 실무에서 거의 쓸 일이 없다!)
id
를 조심해야 한다. id
를 조작해서 접근하면 다른 사람의 데이터를 수정할 수도 있기 때문에, 해당 사용자가 해당 아이템에 대해 권한이 있는지 여부를 체크하는 로직이 있어야 한다.아주아주아주 중요하다!
영속성 컨텍스트가 더이상 관리하지 않는 엔티티이다.
다음과 같이 new
로 임의로 만들어낸 엔티티(book
)도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다.
엔티티 객체가 이미 DB에 한 번 저장되어 식별자가 존재하는 것이다!
@PostMapping("items/{itemId}/edit")
public String updateItemForm(@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";
}
하지만 준영속 엔티티는 JPA가 관리하지 않기 때문에, 준영속 엔티티를 수정하려면 다음의 두 가지 방법을 사용해야 한다.
merge
) 사용영속성 컨텍스트에서 엔티티를 다시 조회한 후에 수정하는 방법이다.
트랜잭션 안에서 엔티티를 다시 조회하고 원하는 속성만 변경하면, 트랜잭션 커밋 시점에 변경 감지(dirty checking)가 동작하여 데이터베이스에 update 쿼리가 실행된다.
public class ItemService {
private final ItemRepository itemRepository;
...
@Transactional
public void updateItem(Long itemId, Book param) {
// findItem은 영속 상태이다!
Item findItem = itemRepository.findOne(itemId);
findItem.setPrice(param.getPrice());
findItem.setName(param.getName());
findItem.setStockQuantity(param.getStockQuantity());
// 따라서 영속화하기 위한 작업을 따로 하지 않아도 된다.
// 트랜잭션이 완료되면 flush 되며, 그때 변경 감지로 인해 업데이트 쿼리가 날아간다.
}
[!caution] 또한, 위의 코드처럼 업데이트를 단발성으로 하면 안된다.
엔티티에서 의미 있는 메서드(ex.
change(...)
)로 따로 빼야 추후 변경 지점이 엔티티로 한정되게 된다.엔티티 레벨에서 변경되는 로직을 작성하는 것을 권장한다.
준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.
병합의 동작 방식은 다음과 같다.
UPDATE
SQL이 실행된다.[!caution] 원하는 속성만 선택할 수 있는 변경 감지 기능과는 다르게, 병합 시에는 모든 속성이 변경된다.
따라서 병합 시 값이 없으면 기존 값이 유지되는 것이 아닌,
null
로 업데이트 할 위험도 있다. 실무에서는 보통 변경을 원하는 데이터만 노출하므로 병합을 사용하면 번거로워지게 된다.→ 실무에서는 변경 감지 기능을 권장한다!!
[!tip] 엔티티를 변경할 때는 항상 변경 감지를 사용하자!
트랜잭션이 있는 서비스 계층에 식별자(id
)와 변경할 데이터를 명확하게 전달하라.
파라미터 or DTO
예시 코드는 다음과 같다.
좋지 않은 코드
어설프게
Book
생성
@PostMapping("items/{itemId}/edit")
public String updateItemForm(@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";
}
좋은 코드
엔티티를 넘기는 것이 아닌, 변경 대상이 되는 값이나 DTO를 넘기는 방법
@PostMapping("items/{itemId}/edit")
public String updateItemForm(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) {
// 다음과 같이 변경 대상이 되는 값이나 DTO를 만들어(ex. UpdateItemDto) 넘기는 방법을 사용한다.
itemService.updateItem(itemId, form.getPrice(), form.getName(), form.getStockQuantity());
return "redirect:/items";
}
서비스 계층에서는 다음과 같은 코드를 작성한다. (단, setter 대신 의미있는 메서드 사용을 권장한다.)
@Transactional
public void updateItem(Long itemId, int price, String name, int stockQuantity) {
// findItem은 영속 상태이다!
Item findItem = itemRepository.findOne(itemId);
findItem.setPrice(price);
findItem.setName(name);
findItem.setStockQuantity(stockQuantity);
// 따라서 영속화하기 위한 작업을 따로 하지 않아도 된다.
// 트랜잭션이 완료되면 flush 되며, 그때 변경 감지로 인해 업데이트 쿼리가 날아간다.
}
[!tip] setter 대신 엔티티 안에서 바로 추적할 수 있는 메서드를 만들자. (ex.
findItem.change(...)
)업데이트 코드를 변경해야 하는데, 해당 코드가 여기저기에 퍼져있다고 생각해보자..!
[!tip] 서비스 계층에 식별자를 넘기자!
핵심 비즈니스 로직이 있는 경우(ex. 단순 조회 제외) 서비스 계층에 엔티티를 직접 찾아서 넘기기보다는 식별자를 넘기자. 이렇게 하면 서비스 계층의 로직 안에서 할 수 있는 게 더 많아진다. 엔티티도 영속 상태로 흘러가기 때문이다. (→ dirty checking)
트랜잭션이 걸려있는 서비스 계층 밖에서 엔티티를 찾으면 영속 상태가 아니게 된다!
- 컨트롤러
```java
@PostMapping("/order")
public String roder(@RequestParam("memberId") Long memberId,
@RequestParam("itemId") Long itemId,
@RequestParam("count") int count) {
// 식별자 넘기기 (엔티티 대신)
orderService.order(memberId, itemId, count);
return "redirect:/orders";
}
```
서비스
@Transactional
public Long order(Long memberId, Long itemId, int count) {
// 엔티티 조회
Member member = memberRepository.findOne(memberId);
Item item = itemRepository.findOne(itemId);
...
[!tip] 서비스에서 단순히 리포지토리에 동작을 위임만 하는 경우라면, 컨트롤러에서 바로 리포지토리를 불러와 사용해도 된다. 원하는대로!
public List<Order> findOrders(OrderSearch orderSearch) { return orderRepository.findAll(orderSearch); }
Contents
H2 URL
처음 파일을 생성할 때는
tcp://localhost/
을 제외하고 파일 모드로 접속하고, 그 후에는tcp://localhost/
를 추가하여 네트워크 모드로 접속한다.하지만, 테스트 코드만 돌릴 것이라면 메모리 DB를 사용할 수 있으므로 H2를 켜지 않아도 된다.
Note
도메인 분석 설계
https://github.com/seungriyou/spring-study/tree/21465dfa71b444bb478d9af72cfa27eb34114005/04-jpa_1_app/jpashop/src/main/java/jpabook/jpashop/domain