Programming-[Backend]/JPA

[JPA활용-1] 3. 도메인 개발 : Unique 제약 조건, main/test 환경 설정 분리, JPQL 동적 쿼리 등

컴퓨터 탐험가 찰리 2021. 11. 10. 21:28
728x90
반응형

 

 

스프링의 @Repository에 JPA의 @PersistenceContext 추가

 

 

그냥 스프링만 이용할 때는 @Repository 어노테이션을 추가하여 @Component 로서 Component Scan이 되게 하였다. 여기에 @PersistenceContext 어노테이션으로 EntityManager를 불러오면, 스프링이 EntityManager를 만들어서 Repository에 주입해주게 된다.

 

Member 객체와 관련한 기본적인 기능을 갖는 MemberRepository를 보면, EntityManager를 @PersistenceContext 어노테이션과 함께 작성한 것을 확인할 수 있다.

 

추가로, 엔티티 자체를 들고 와야하거나 특정 파라미터를 추가해야된다면, 기본편에서 배운 JPQL 문법을 사용해야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Repository
public class MemberRepository {
 
  @PersistenceContext
  private final EntityManager em;
 
  public void save(Member member) {
    em.persist(member);
  }
 
  public Member findOne(Long id) {
    return em.find(Member.class, id);
  }
 
  public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
  }
 
  public List<Member> findByName(String name) {
    return em.createQuery("select m from Member m where m.name = :name", Member.class)
            .setParameter("name", name)
            .getResultList();
  }
  
}
cs

 


 

스프링 부트 라이브러리에서 @PersistenceContext 부분을 @Autorwired로 처리할 수 있게 해준다.

 

따라서, 굳이 @PersistenceContext를 안쓰고 아래와 같이 작성해도 된다.

 

1
2
3
4
5
6
7
8
9
10
11
@Repository
@RequiredArgsConstructor
public class MemberRepository {
 
  private EntityManager em;
 
  public void save(Member member) {
    em.persist(member);
  }
 
....
cs

 

 


 

@Transactional(readOnly = true)의 의미

 

readOnly로 설정하면 JPA에서는 dirty checking을 하지 않는 등 성능상의 이점을 부여한다. 그리고 DB에도 특정 드라이버에서는 읽기 전용임을 전달하여 불필요한 작업을 안할 수 있게 해준다.

 

서비스 클래스 전체 단위에서 많이 사용하는 것이 readOnly라면, 클래스 단에서 이것을 정의해주고, 나머지 메서드에는 각각 @Transactional을 달아주는 것이 좋다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
 
  private final MemberRepository memberRepository;
 
 
  /**
   * 회원 가입
   * @param member
   * @return
   */
  @Transactional
  public Long join(Member member) {
    //중복 회원 검증
    validateDuplicateMember(member);
    memberRepository.save(member);
    return member.getId();
  }
 
  private void validateDuplicateMember(Member member) {
    //EXCEPTION
    List<Member> findMembers = memberRepository.findByName(member.getName());
    if (!findMembers.isEmpty()) {
      throw new IllegalStateException("이미 존재하는 회원입니다.");
    }
  }
 
  //회원 전체 조회
//  @Transactional(readOnly = true)
  public List<Member> findMembers() {
    return memberRepository.findAll();
  }
 
//  @Transactional(readOnly = true)
  public Member findOne(Long memberId) {
    return memberRepository.findOne(memberId);
  }
 
}
cs

 

 


 

[중요] 동시성 문제 : DB에 Unique 제약 조건을 걸어준다

 

validateDuplicateMember 와 같은 간단한 로직은, 멀티 쓰레드로 여러 개의 WAS가 작동하여 동시에 같은 이름의 member가 메서드를 타게 되는 경우 DB에 save가 안된채로 메서드를 통과 후, 같은 이름의 member가 DB에 저장되는 문제가 발생할 수 있다. 이런 경우, DB 자체에서 member의 name에 Unique 제약조건을 걸어두어서 이런 문제를 방지해야한다.

 

 

 


 

assertEquals, try-catch문으로 error 처리

 

테스트 코드를 검증할 때, 보통 Assertions.assertThat을 사용해서 isEqualTo() 메서드를 활용했다. 이 Assertions는 assertj에서 import 해오고, 에러 던지는 걸 검증할 때는 Assertions가 junit의 assertThrows 메서드를 import 했었다.

 

그런데, junit에서 제공하는 Assert.assertEquals(비교대상1, 비교대상2)를 사용하면 static import를 1개만 해도 될 것 같다. 참고로 알아두자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
  @DisplayName("")
  void 회원가입() throws Exception {
    //given
    Member member = new Member();
    member.setName("kim");
 
    //when
    Long savedId = memberService.join(member);
 
    //then
    //import static org.junit.jupiter.api.Assertions.*;
    assertEquals(member, memberRepository.findOne(savedId));
 
  }
cs

 

 

 


 

[중요] main과 test의 환경설정 분리,

스프링부트의 자동 메모리 데이터베이스 적용

 

아래 사진과 같이, main 디렉터리가 아닌 test 디렉터리에서 resources 디렉터리를 만들고, 거기에 application.yml 파일을 복사한다. 그리고 datasource:url을 h2 cheat sheet(https://www.h2database.com/html/cheatSheet.html) 에서 제공하는 In 메모리 DB 전용 url 정보를 넣어준다.

 

그러면 실제 H2 database Console을 실행하지 않더라도, In-memory 방식의 H2 database로 연결되어 테스트가 진행되는 것을 확인할 수 있다. test 디렉터리는 내부에 resources 디렉터리가 있다면 해당 디렉터리 내부의 환경설정 정보를 main 디렉터리의 환경설정 정보보다 우선적으로 참조한다.

 

한 가지 더, 아래와 같이 DB 커넥션 정보 자체를 주석처리 해버려도 스프링부트가 알아서 in-memory 환경의 database로 연결해준다. h2 database 라는 것은 build.gradle에서 알려주었기 때문에, 스프링부트가 스스로 h2 database의 in-memory url을 설정해준다.

 

그리고 스프링부트가 자동으로 지정해주는 경우는 ddl-auto 옵션이 create-drop이기 때문에 커넥션 종료 시점에 다시 drop을 해주어 자원 정리까지 모두 처리해준다.

 

 


 

객체 생성 제한하기 :

@NoArgsConstructor(access = AccessLevel.PROTECTED)

 

OrderItem.createOrderItem으로 특정 메서드를 통해 OrderItem 객체를 생성할 때는 특정 로직만 적용되도록 제한을 둬야할 수 있다. 이렇게 하기 위해서는 그 아래 new 키워드로 OrderItem()을 생성하고 setter를 통해서 정형화된 OrderItem 객체가 아닌 객체를 누군가가 생성하는 것을 막아야한다.

 

이렇게 제약 조건을 두면 해당 객체는 항상 틀을 갖고 생성되기 때문에 코드의 일관성이나 유지보수 측면에서 유리할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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());
 
    //주문 상품 생성
    OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
 
    OrderItem orderItem1 = new OrderItem();
 
    //주문 생성
    Order order = Order.createOrder(member, delivery, orderItem);
 
    //주문 저장
    orderRepository.save(order);
    return order.getId();
  }
cs

 

이를 위해서 OrderItem의 기본생성자를 protected()로 변경하거나, @NoArgsConstructor의 access 속성을 AccessLevel.PROTECTED로 설정할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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 void changeOrder(Order order) {
    this.setOrder(order);
    order.getOrderItems().add(this);
  }
 
//  protected OrderItem() {
//
//  }
cs

 

 

기본 생성자를 통해 OrderItem 객체를 생성하지 못하는 것을 확인할 수 있다.

 

 

 


intelliJ 단축키 : Ctrl + Alt + P, 메서드에 파라미터 추가하기

 

메서드의 내용을 작성하다가 파라미터가 추가로 필요한 경우, 커서를 해당 변수에 둔 채로 Ctrl + Alt + P를 누르면 해당 메서드의 파라미터로 바로 등록할 수 있다.

 

 

 


JPQL에 동적 쿼리 적용하기

 

검색어 파라미터 orderSearch를 적용하여 JPQL 쿼리문에 삽입할 수 있다. 그런데, 사용자의 orderSearch 입력값 중 status나 name을 입력하지 않는 경우 그 파라미터들이 null이 되어 NullPointerException을 발생시킬 수 있다. 입력값이 있느냐 없느냐에 따라서 다르게 쿼리문이 적용(동적 쿼리)되어야 한다.

 

1
2
3
4
5
6
7
8
9
public List<Order> findAll(OrderSearch orderSearch) {
 
    return em.createQuery("select o from Order o join o.member m " +
                    " where o.orderStatus = :status " +
                    " and m.name like :name", Order.class)
            .setParameter("status", orderSearch.getOrderStatus())
            .setParameter("name", orderSearch.getMemberName())
            .getResultList();
  }
cs

 

방법1. 조건문으로 분기하여 String 더하기

 

단순한 방법으로는 아래와 같이 조건문으로 분기하고 문자열을 추가해주면서 동적 쿼리를 생성할 수 있다. jpql 쿼리문을 where, and 등을 어떻게 추가할지를 orderSearch의 검색어 하나마다 작성해줘야하고, 여기에 파라미터를 추가하는 구문도 전부 조건문으로 분기하여 처리해주어야 한다.

 

코드가 상당히 길어지고, String을 더해가는 방식이다 보니 실수가 발생할 수 있고 번거롭다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public List<Order> findAllByString(OrderSearch orderSearch) {
 
    String jpql = "select o from Order o join o.member m ";
    boolean isFirstCondition = true;
 
    //주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
      if (isFirstCondition) {
        jpql += " where";
        isFirstCondition = false;
      } else {
        jpql += " and";
      }
      jpql += " o.status = :status";
    }
    //회원 이름 검색
    //...
 
    TypedQuery<Order> query = em.createQuery(jpql, Order.class);
 
    //주문 상태 파라미터 추가
    if (orderSearch.getOrderStatus() != null) {
      query = query.setParameter("status", orderSearch.getOrderStatus());
    }
 
    //회원 이름 파라미터 추가
    //...
 
    return query.getResultList();
  }
cs

 

 

방법2. JPA가 제공하는 Criteria, Predicate 이용하기

 

추천되는 방법은 아니지만, JPA의 표준 중 Criteria와 Predicate를 이용하는 방법이 있다. 그러나 가독성이 떨어지고 유지보수성이 떨어지는 방법이다. 그냥 이런 스펙이 있다는 정도만 알면 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
   * JPA Criteria
   * @param orderSearch
   * @return
   */
  public List<Order> findAllByCriteria(OrderSearch orderSearch) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> cq = cb.createQuery(Order.class);
    Root<Order> o = cq.from(Order.class);
 
    Join<Object, Object> m = o.join("member", JoinType.INNER);
 
    List<Predicate> criteria = new ArrayList<>();
 
    //주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
      Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
      criteria.add(status);
    }
    //회원 이름 검색
    if (StringUtils.hasText(orderSearch.getMemberName())) {
      Predicate name = cb.like(m.<String>get("name"), "%" + orderSearch.getMemberName() + "%");
      criteria.add(name);
    }
 
    cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
    TypedQuery<Order> query = em.createQuery(cq);
    return query.getResultList();
 
  }
cs

 

 

방법3. Querydsl 사용하기

 

상기 단점들이 있기 때문에 querydsl을 사용한다. querydsl로 변경하면 아래와 같이 BooleanExpression을 이용해서 매우 간단하고 직관적인 방식을 적용할 수 있다. 나중에 Querydsl 강의에서 다시 배우게 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public List<Order> findAll(OrderSearch orderSearch) {
  
 QOrder order = QOrder.order;
 QMember member = QMember.member;
 
 return query
        .select(order)
        .from(order)
        .join(order.member, member)
        .where(statusEq(orderSearch.getOrderStatus()),
                nameLike(orderSearch.getMemberName()))
        .limit(1000)
        .fetch();
}
 
private BooleanExpression statusEq(OrderStatus statusCond) {
    if(statusCond == null) {
        return null;
    }
    return order.status.eq(statusCond);
}
 
private BooleanExpression nameLike(String nameCond) {
    if(!StringUtils.hasText(nameCond) {
        return null;
    }
    return member.name.like(namecond);
}
cs

 


 

참조

 

1. 인프런_실전! 스프링 부트와 JPA 활용1_김영한 님 강의

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-%ED%99%9C%EC%9A%A9-1/dashboard

728x90
반응형