inflearn 김영한님의 강의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 중 도메인 개발 챕터를 듣고 배운것을 정리한 내용입니다.
SpringBoot 와 JPA 의 장점을 활용하며 Application 을 잘 만드는 방법에 대한 이야기
Entity 설계 -> Repository, Service 개발 -> 테스트 -> 웹 (+ 컨트롤러) 개발
엔티티 생성 시 복잡한 과정이 필요하다면 별도의 생성 메서드를 만들자.
예를들어, 상품주문을 생성할 때 주문한 아이템을 찾아 재고 상황을 꼭 업데이트 해야한다면 다음과 같이 생성 메서드를 만들 수 있다.
// orderPrice를 따로 받는 이유는 할인이나 등급등으로 회원마다 다른 주문금액이 될 수 있기 때문이다.
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
// Order Item 생성
OrderItem orderItem = new OrderItem(item, orderPrice, count);
// 재고 까기
item.removeStock(count);
return orderItem;
}
생성 메서드를 만들었다면, 이제 createOrderItem 메서드를 통해서 orderItem 을 생성하면 된다.
그런데 직접 개발한 나는 이렇게 쓰지만, 다른 누군가는 내 의도를 몰라줄 수 있지 않을까?
직접 new OrderItem() 을 해서 채워넣고 사용하다 미처 removeStock을 해야하는 것을 놓칠수도 있지 않을까?
내 의도를 조금 더 명확히 드러내기 위해 OrderItem 의 기본 생성자를 protected 로 만들어주자.
private 은 안되고 JPA 사용 시 protected 가 최선이다. 이렇게 하면 적어도 외부에서 생성 시 컴파일 오류를 낼 것이고, 의도를 조금 더 명확히 나타낼 수 있다.
예를들면 Entity의 status 라는 필드 값을 바꾸는 turnOff() 같은 애들이 될 수 있다.
Setter 를 다 열어두는 것 보다 이렇게 의미가 있는 method 를 만들어 놓는 것이 좋다.
Entity 안에서 데이터를 변경하는 비지니스 로직들은 Entity 안에 넣어주자.
조금 더 객체 지향적이고 응집도가 높은 설계를 기대할 수 있다.
자신의 필드 뿐 만 아니라 자신과 연관된 객체를 수정해야 하는 경우도 마찬가지다.
예를들어, OrderItem 엔티티가 item 이란 필드를 가지고 있는 상황에서, cancel() 이라는 주문 취소 비즈니스 로직을 구현하여 주문 취소가 들어왔을 경우 item.addStock(count)
를 호출할 수 있다.
@Entity
public class OrderItem {
/*...*/
private Item item;
/*...*/
public void cancel() {
getItem().addStock(count);
}
}
Entity의 Getter 는 열어두어도 큰 문제는 없지만, Entity의 필드를 활용하여 약간의 계산을 요구하는 조회 로직들도 Entity method 로서 구현할 수 있다.
예를들어 주문한 상품의 총액을 주는 경우
pubilc int getTotalPrice() {
return getOrderPrice() * getCount();
}
와 같은 조회 로직을 만들 수 있다.
@Entity
public class Order {
/* ... */
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
private Delivery delivery;
}
cascade 설정을 해 두면 Order 를 save() 때린 순간 관련된 Entity 들도 알아서 persist 가 날아간다.
cascade는 편하지만 위험하기 때문에 조심히 사용할 필요가 있다.
예제 프로젝트 기준 OrderItem, Delivery 는 OrderItem 이외의 다른 곳에서 참조하지 않는다. OrderItem, Delivery 를 참조하는 애는 Order 가 유일하므로 cascade를 줘도 위험하지 않다.
다른곳에서 참조되는 entity에 cascade 를 걸면 위험하다. 그럴 경우 각각 persist 를 하는것이 낫다.
원래 entity manager 를 주입받기 위해서는 @PersistenceContext
를 위에 달아줘야 했다.
하지만 Spring Data JPA 가 Autowired 로 적혀 있어도 injection 해준다. 그래서 생성자 주입을 사용한다.
private final EntityManager em;
persist() 는 영속성 Context 에 객체를 넣는다는 의미이다.
실제 쿼리는 Transaction 이 commit 되는 시점에 날아간다.
findAll 메소드는 JPQL 을 통해 가져온다.
SQL이 테이블에 대해 쿼리하는 것 이라면, JPQL 은 Entity에 대해 쿼리하는 것이다. (from 의 대상이 객체)
em.createQuery("select m from Member m", Member.class).getResultList();
조건 검색을 만드는 경우 조건을 줄 수도, 안줄 수도 있다.
그렇다면 동적으로 쿼리를 생성해야 하는데 어떻게 하는것이 좋을까?
일일이 각 파라미터가 존재하는지 아닌지 체크하여 수행
=> 딱 봐도 복잡하고 유지보수하기 어렵다.
JPA Criteria 사용
=> 동적쿼리 처리하는 JPA 표준이나, 권장하지 않는다. 실무에서 안쓴다.
Querydsl
=> 가장 아름다운 방법. 뒤에서 자세히 설명 예정
서비스에서는 일반적으로 @Transactional
을 사용한다. 비즈니스 로직이 시작되는 곳이라 중간 어떤 지점에서 문제가 생기든 롤백시켜버릴 수 있기 때문이라고 생각한다.
import
@Transactional 은 javax 와 spring 에서 제공하는것 2가지가 있는데, 어차피 스프링 의존적이므로 기왕이면 편의기능이 더 많은 spring 의 Transactional 을 사용한다.
readOnly
@Transactional
에는 readOnly 라는 속성이 있는데 이것을 true 로 설정하면 JPA가 조회 시 조금 더 최적의 성능을 낼 수 있다.
(JPA 가 영속성 컨텍스트를 flush 안하고 더티체킹을 안함으로 생기는 이점과 DB에 따라 읽기 전용 Transaction 을 최적화 해서 리소스를 관리하기도 한다)
말 그대로 읽기 전용이어서 변경을 가하는 작업이나 쓰기에는 사용하면 안된다. 반영이 안되거든
일반적으로 class 위에 전역으로 거는 곳에 @Transactional(readOnly=true)
를 주고, 변경을 가하는 메서드 위에 @Transactional
을 따로 준다. (default는 false)
위 Entity 설명에서 보았듯 비즈니스 로직 대부분을 Entity 에 넣었다.
그럼 서비스에선 뭘 할까?
단순히 Entity 들을 가져와서 해당 entity 에서 정의한 method 들을 호출하는 것 밖에 없을것이다. 즉 Service 가 엔티티에 단순히 필요한 요청을 위임하는 역할만 할 것이다.
이런 방식으로 설계하는 것을 Domain Model Pattern 이라고 한다.
반면, 서비스단에서 복잡한 비지니스 로직을 처리하도록 하는 것을 Transaction Script 라고 한다.
JPA를 쓰면 주로 Domain Model Pattern을, SQL 을 직접 다루는 경우에는 Transaction Script 패턴을 많이 사용한다. 상황에 따라 유지보수성이 높은 방식을 선택 해야한다.
단순히 이해하기론 Domain Model 은 엔티티에서 비즈니스 로직을 하겠다는 것이고 Transaction Script 는 서비스에서 비즈니스 로직을 수행하겠다는 것인데 둘 간의 장단점을 비교할 수 있을 정도로 명확히 이해하지 못했다.
https://martinfowler.com/eaaCatalog/
https://medium.com/hackernoon/making-a-case-for-domain-modeling-17cf47030732
엔티티 생성 시 꼭 한번쯤은 하게되는 로직. 예를들어 Name 필드는 겹치지 않아야 한다고 하자.
findByName(name) 을 통해 찾아본 후 없으면 해당 값으로 생성할 수 있게 하는것이 일반적일텐데, 동시성 문제가 있다. 동시에 두 쓰레드가 같은 이름으로 생성하기 위해 dup check 을 한다면 둘다 insert 될 테니까... 그래서 실무에선 DB 쪽의 name 에 UniqueKey
를 줘서 최후의 방어를 해준다.
또한 중복 검사를 위해 날리는 쿼리로 객체를 가져오지 말고 숫자만 반환하게 해서 0보다 크면 문제가 있다는 식으로 더 최적화 할 수 있다.
만일, 서비스의 모든 메서드가 단순히 Repository 로의 위임만 한다면, 굳이 이 서비스가 필요할까? 바로 컨트롤러에서 보내줘도 되지 않을까?
김영한 선생님 가라사대: 컨트롤러에서 바로 주는것도 나쁘다고 생각하지는 않는다.
특정 Domain의 custom Exception 을 구현할 시 RuntimeException 을 상속받은 후 아래 4개의 메소드를 Override 해주자.
public MyCustomException() {
super();
}
public MyCustomException(String message) {
super(message);
}
public MyCustomException(String message, Throwable cause) {
super(message, cause);
}
public MyCustomException(Throwable cause) {
super(cause);
}
@RunWith(SpringRunner.class) // junit 실행할 때 spring 하고 엮어서 실행할래
@SpringBootTest // 스프링 부트 띄워두고 테스트할래
@Transactional // Test 끝난 후 다 Rollback 할래
public class MyTest {}
만일, @Transactional 로 인한 자동 Rollback이 싫다면 @Rollback(false)
옵션을 주면 롤백하지 않는다.
실제 테스트 코드에서 날아간 로그를 보면 insert 테스트 했던 것은 없고 select 쿼리들만 찍혀있다.
persist() 만으로는 기본적으로 insert 가 안날아간다. transaction 을 commit 해야 flush 가 되면서 영속성 컨텍스트 전체가 insert 로 쭉 날아가는데,
@Transactional
덕분에 테스트 후 롤백이 일어나서 영속성 컨텍스트가 flush 되지 않는다.
(insert 가 날아가는지 궁금하면 flush 해보자. 로그 찍힌다)
DB 연결 신경 안쓰고 테스트 만큼은 독립적이고 편하게 하고싶다! 인메모리 디비로 설정해서 한번쓰고 버려버리자.
src/test/resources
를 생성하고 여기에 application.yml 을 넣으면 test 환경 전용 설정을 관리할 수 있다. (필수적으로 하자)
http://h2database.com/html/cheatSheet.html
위 주소에 들어가보면 spring.datasource.url 에 설정할 inmemory 주소가 나온다.
그런데 걍 아무것도 안적어도 스프링 부트가 test 환경에 있는 application.yml 의 datasource 는 inmemory를 바라본다...
결론적으로 test 환경용 application.yml 만 만들어서 넣어주자. (h2 를 사용하므로 gradle에 h2는 꼭 있어야 한다)
Repostiry 에서 save 해서 영속성 컨텍스트에 persist 한 경우 PK 에 GeneratedValue 를 지정한 경우 만들어서 올린다. 영속성 컨텍스트는 key, value 로 관리되는데 PK 값이 key 가 된다. 즉 쿼리를 flush 안하더라도 persist 후 getId 때리면 아이디가 만들어져 있다. 생성한 객체를 조회하는데 들어가는 쿼리가 필요 없겠다.
직접 SQL 을 다루는 애들의 경우 엔티티의 상태를 변경한 후 업데이트 쿼리를 직접 날려줘야 한다. 하지만, JPA 는 엔티티 안의 데이터만 바꿔주면 JPA가 dirty checking(변경점 감지) 를 통해 변경된 부분의 업데이트 쿼리가 알아서 날아간다.
잘 정리된 글
https://madplay.github.io/post/why-constructor-injection-is-better-than-field-injection
intellij 설정에서 live template 을 만들어 놓으면 테스트 코드 개발 속도가 빨라진다.
아래 코드를 tdd 로 등록해두자
@Test
public void $methodName$() throws Exception {
//given
$END$
//when
//then
}
새로 알게 된 intellij 단축키