본문 바로가기

IT/Spring JPA

JPA 웹 어플리케이션 개발

반응형

웹 애플리케이션 만들기 진행 순서 

- 프로젝트 환경설정 (프로젝트 구조, 메이븐과 라이브러리 설정, 스프링 프레임워크 설정)

- 도메인 모델과 테이블 설계

- 애플이케이션 기능 구현

 

프로젝트 환경설정

[JPA 사용을 위한 예외적인 설정]

 

1. 트랙잭션 관리자를 DatasourceTransactionManager가 아닌 JpaTransactionManager로 등록해야 됨

(JPA + JdbcTeplate + Mybatis 함께 사용가능)

<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
        <property name="dataSource" ref="dataSource"/>
</bean>

 

2. JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환하는 AOP 적용

<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/>

 

3. 스프링 프레임워크에서 JPA를 사용하려면 스프링 프레임워크가 제공하는 엔티티 매니저 팩토리를 등록해야 됨

<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
    <property name="dataSource" ref="dataSource"/>
    <property name="packagesToScan" value="jpabook.jpashop.domain"/> <!-- @Entity 탐색 시작 위치 -->
    <property name="jpaVendorAdapter">
    	<!-- 하이버네이트 구현체 사용 -->
        <bean class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter"/>
    </property>
    <property name="jpaProperties"> <!-- 하이버네이트 상세 설정 -->
    	<props>
            <prop key="hibernate.dialect">org.hibernate.dialect.H2Dialect</prop> <!-- 방언 -->
            <prop key="hibernate.show_sql">true</prop>                   <!-- SQL 보기 -->
            <prop key="hibernate.format_sql">true</prop>                 <!-- SQL 정렬해서 보기 -->
            <prop key="hibernate.use_sql_comments">true</prop>           <!-- SQL 코멘트 보기 -->
            <prop key="hibernate.id.new_generator_mappings">true</prop>  <!-- 새 버전의 ID 생성 옵션 -->
            <prop key="hibernate.hbm2ddl.auto">create</prop>             <!-- DDL 자동 생성 -->
        </props>
	</property>
</bean>

hibernate.id.new_generator_mappings : 항상 true이길 권장 (레거시 하이버네이트를 쓰는 경우 X)

hibernate.hbm2ddl.auto : create, create-drop, update, validate(다르면 경고, 실행 X)

 

도메인 모델과 테이블 설계

[도메인 모델 그림]

회원, 주문, 상품의 관계 : 회원 - 주문 (일대다), 주문 - 상품 (다대다) 

다대다 관계는 항상 중간에 매핑테이블을 통해 일대다, 다대일 관계로 풀어야 됨

상품 분류 : 도서, 음반, 영화가 상품이라는 공통 속성을 사용함으로 상속 받는 구조

 

[테이블 설계 그림]

회원 : Address가 임베디드 타입임으로 테이블에 그대로 들어감 (배송도 동일)

아이템 : 자식구조인 도서, 음반, 영화의 엔티티를 다 포함, 타입 구분을 위한 DTYPE이라는 컬럼 추가

 

연관관계 : 양방향인 경우 연관관계의 주인을 정해야됨, 외래키를 갖고 있는 테이블이 연관관계의 주인이 됨

1. 회원 - 주문 : 주문이 연관관계 주인

2. 주문상품 - 주문 : 주문상품이 연관관계 주인

3. 주문상품 - 상품 : 주문상품이 연관관계 주인

4. 주문 - 배송 : 주문이 연관관계 주인

5. 카테고리 - 상품 : @ManyToMany로 세팅 (실무에선 사용 X, 일부로 설정)

실무에서 다대다 매핑을 안쓰는 이유는 중간 매핑 테이블에 보통 매핑용 정보 말고도 다른 정보들을 제공할 수 있음으로

다대다로 매핑할 경우 그 추가 정보들을 사용할 수 없게 제한하는 것임으로 실무에서 사용하지 않는다.

참고 링크 : https://velog.io/@conatuseus/JPA-%EB%8B%A4%EC%96%91%ED%95%9C-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91

더보기

연관관계의 주인 : 테이블은 외래키 하나로 두개의 테이블의 연관관계를 관리하지만, 엔티티는 단방향으로만 테이블을 참조해서 사용할 수 있다. 즉, 양방향인 경우 서로 단방향으로 참조하는 상태이다. (2개의 방향 화살표 존재)

그러므로 2개의 연관관계 중 하나를 선택하여 외래키 역할을 부여하는걸 연관관계의 주인이라고 할 수 있다.

(연관관계 주인은 외래키 등록, 수정, 삭제 가능 주인이 아닌 쪽은 오직 읽기만 가능)

 

참고 링크 : https://jeong-pro.tistory.com/231

 

[연관관계 주인인 객체의 도메인 소스]

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID")
private Member member;      //주문 회원

 

[주인이 아닌 객체의 도메인 소스]

@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<Order>();

 > 추가적으로 cascade 속성을 세팅할 수 있음 (= 연관관계가 있는 엔티티의 변경을 함께 적용하고 싶은 경우 설정)

ALL - 모두 적용 (persist + remove), PERSIST - 영속, REMOVE - 삭제 등등 

연관관계가 있는 컬럼에 Cascade를 안할 경우 부모 클래스는 정상적으로 데이터 변경이 발생하지만, 자식 클래스에서 외래키 무결성 예러가 발생할 수 있음 그러므로 실무에서 최대한 ALL로 설정하길 권장

 

> fetch 설정 가능 : 연관관계가 있는 엔티티에 대한 조회가 발생할때 참조 객체에 대한 조회가 함께 진행할지는 선택 가능 

@ManyToOne, @OneToOne은 기본값이 EAGER임으로 반드시 LAZY로 설정해줘야 됨 (성능적인 이슈)

즉시로딩 : 바로 바로 함께 조회 (fetch = FetchType.EAGER)

지연로딩 : 정말 실제로 그 객체를 필요한 경우에만 조회 (fetch = FetchType.LAZY)

더보기

즉시로딩 vs 지연로딩 

참고 링크 : https://ict-nroo.tistory.com/132

 

개발 방법

[계층 의존관계 그림]

컨트롤러 : MVC의 컨트롤러가 모여있는 곳

서비스 : 비즈니스 로직과 트랜젝션 시작점

레포지토리 : JPA 직접 사용하는 곳 엔티티 매니저를 사용해서 엔티티를 저장 및 조회

도메인 : 엔티티가 모여있는 곳

 

@Repository 어노테이션을 붙여 JPA 예외가 발생하더라도 스프링 추상 예외인 org.springframwork.dao.EmptyResultDataAccessException으로 변환하도록 설정!!

그래야 서비스 계층에서 JPA 예외를 따로 처리하지 않아도 됨.

 

@PersistenceContext 어노테이션으로 컨테이너가 관리하는 엔티티 매니저를 주입해줘야 됨

(엔티티 매니저 팩토리에서 엔티티 매니저를 직접 생성하지 않고 어노테이션으로 주입)

만약, 직접 엔티티 매니터 팩토리를 사용해야 될 경우 @PersistenceUnit 사용

 

JPQL과 QueryDsl를 사용해서 상세 쿼리를 작성할 수 있음 

public List<Member> findByName(String name) {
        return em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
    }

@Transactional 어노테이션을 붙여 클래스나 메소드에 트랜젝션 적용, RuntimeException, 언체크 예외가 발생하면 롤백됨.

 

 

회원 서비스 클래스내

   private void validateDuplicateMember(Member member) {
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if (!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

위와 같은 검증 로직이 존재하더라도 멀티 쓰레드 환경을 고려해서 회원명 컬럼에 유니크 제약을 추가할 수 있음 (안정성 up)

 

 

테스트 코드 작성 예시

 

테스트 코드 작성 구조는 given(테스트할 상황 설정), when(테스트 대상 실행), then(테스트 결과 검증)으로 작성한다.

    @Test
    public void 상품주문() throws Exception {

        //Given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고
        int orderCount = 2;

        //When
        Long orderId = orderService.order(member.getId(), item.getId(), orderCount);

        //Then
        Order getOrder = orderRepository.findOne(orderId);

        assertEquals("상품 주문시 상태는 주문(ORDER)이다.", OrderStatus.ORDER, getOrder.getStatus());
        assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다.", 10000 * 2, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 8, item.getStockQuantity());
    }

 

일반적인 상품 주문에 대한 테스트 코드를 작성할 수 있음

    @Test(expected = NotEnoughStockException.class)
    public void 상품주문_재고수량초과() throws Exception {

        //Given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고

        int orderCount = 11; //재고 보다 많은 수량

        //When
        orderService.order(member.getId(), item.getId(), orderCount);

        //Then
        fail("재고 수량 부족 예외가 발생해야 한다.");
    }

위와 같이 특정 예외에 대한 테스트 코드를 작성할 수 있음

(fail이 호출되거나 정의한 NotEnoughStockException 이 발생 안하면 테스트 실패)

 

만약 테스트에서 @Transactional 어노테이션을 작성하면 테스트가 끝나면 트랜젝션을 강제로 롤백한다.

 

주문 도메인 클래스 내 

@Enumerated(EnumType.STRING)
private OrderStatus status;//주문상태

@Enumerated JPA내 enum 형태를 어떻게 정의할 것인지를 설정하는 어노테이션임 (기본값이 int형임으로 String 설정하길 권장)

EnumType.ORDINAL : enum 순서 값을 DB에 저장
EnumType.STRING : enum 이름을 DB에 저장

 

    /** 주문 취소 */
    public void cancel() {

        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new RuntimeException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

 

위와 같이 엔티티내에 필요한 비지니스 로직을 구현함으로써 도메인 모델 패턴을 적용할 수 있다. (서비스는 단순히 엔티티에 요청을 위임)

더보기

도메인 모델 패턴 : 비즈니스 로직 대부분을 엔티티 도메인 클래스에 구현, 객체지향의 특성을 적극 활용한 패턴

트랜젝션 스크립트 패턴 : 엔티티 도메인에 비즈니스 로직 거의 없고, 서비스 계층에서 비즈니스 로직을 처리하는 패턴

 

주문 검색 클래스

package jpabook.jpashop.domain;

/**
 * Created by holyeye on 2014. 3. 15..
 */
public class OrderSearch {

    private String memberName;      //회원 이름
    private OrderStatus orderStatus;//주문 상태

    //Getter, Setter
    public String getMemberName() {
        return memberName;
    }

    public void setMemberName(String memberName) {
        this.memberName = memberName;
    }

    public OrderStatus getOrderStatus() {
        return orderStatus;
    }

    public void setOrderStatus(OrderStatus orderStatus) {
        this.orderStatus = orderStatus;
    }
}

OrderSearch 객체(getter, setter만 존재)를 통해 검색 조건을 전달, 주문 리포지토리 클래스 내 findAll() 메소드를 통해 주문상품을 검색할 수 있음.

 

    public List<Order> findAll(OrderSearch orderSearch) {

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);
        Root<Order> o = cq.from(Order.class);

        List<Predicate> criteria = new ArrayList<Predicate>();

        //주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            criteria.add(status);
        }
        //회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Join<Order, Member> m = o.join("member", JoinType.INNER); //회원과 조인
            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).setMaxResults(1000); //최대 검색 1000 건으로 제한
        return query.getResultList();
    }
더보기

Criteria VS QueryDSL VS JPQL

criteria랑 queryDsl은 JPQL를 만들어주는 빌더 역할일 뿐 핵심 문법은 JPQL임

 

criteria랑 queryDsl은 동적 쿼리를 쉽게 작성할 수 있게 도와주지만, criteria는 문법이 복잡하여 queryDsl을 사용하길 권장함

JPQL의 벌크 연산을 통해 대용량 데이터 처리를 할 수 있음

 

JPA에서도 네이티브 SQL을 통해 직접 쿼리를 작성할 수 있지만 특정 데이터베이스에 종속됨으로 데이터베이스를 변경하기 쉽지 않음으로 최대한 JPQL를 사용하길 권장함

 

변경 감지 기능 사용 

@Transctional
void update (Item item){
	Item findItem = em.find(Item.class, item.getId());
    
    findItem.setPrice(item.getPrice());
}

준영속 엔티티의 값으로 영속 상태의 엔티티를 얻은 다음 원하는 값을 수정하면 트랜젝션 커밋때 변경감지 기능을 통해 Update 됨

 

병합사용

@Transactional
void update(Item item){
	Item mergeItem = em.merge(item)
}

변경 감지와 비슷하게 동작하지만 영속 엔티티 값을 준영속 엔티티의 값으로 모두 채워져서 Update 됨 

 

즉 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경 가능하지만, 병합은 모든 속성이 변경된다.

반응형

'IT > Spring JPA' 카테고리의 다른 글

JPA 성능 최적화  (0) 2021.08.13
JPA 예외처리 및 프록시 심화 익히기  (0) 2021.08.01
JPA 영속성 관리  (0) 2021.07.15
QueryDSL 사용법  (3) 2020.02.17
생성자 제한하기  (0) 2020.02.16