웹 어플리케이션 제작

아래의 구조로 이루어진 웹어플리케이션을 제작하면서 JPA를 익혀보자

  • View: JSP,JSTL
  • BE: Spring MVC
  • DB: JPA, Hibernate
  • Framework: Spring FrameWork Based Project
  • Build: Maven

프로젝트 기본 설정

Maven pom.xml

기본 설정

<modelVersion>4.0.0</modelVersion>
<groupId>jpabook</groupId>
<artifactId>ch11-springdata-shop</artifactId>
<version>1.0-SNAPSHOT</version>
<name>jpa-shop</name>
<packaging>war</packaging>
Tags Description
modelVersion POM 모델 버전 설정 (기본값 유지)
groupId 프로젝트 그룹명 지정
artifactId 프로젝트 식별 아이디
version 프로젝트 버전
name 프로젝트 이름
packaging 빌드 방법, 웹어플리케이션 -> war, 자바 : jar

dependencies

<dependencies>
    <!-- 스프링 MVC -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring-framework.version}</version>
    </dependency>

    <!-- 스프링 ORM -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-orm</artifactId>
        <version>${spring-framework.version}</version>
    </dependency>

    <!-- JPA, 하이버네이트 -->
    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-entitymanager</artifactId>
        <version>${hibernate.version}</version>
    </dependency>
</dependencies>

각종 라이브러리를 설정해준다. groupId + artifactId + version을 이용해서 Maven에서 자동적으로 라이브러리들을 다운로드 한다.

핵심적으로 사용되는 라이브러리는 위에 세개이다.

Libraries Description
Spring MVC 스프링 MVC 라이브러리
Spring ORM 스프링 프레임워크와 JPA 연동을 위한 라이브러리
JPA-하이버네이트 JPA와 Hibernate를 포함하는 라이브러리

이외에도 H2, Connection Pool, WEB, SLF4J, Junit 등과 같은 다양한 라이브러리들이 존재한다.

Spring Framework 설정

스프링 프레임워크를 사용하기 위한 설정

webapp/WEB-INF/web.xml

전체적인 웹 어플리케이션에 대한 설정, Appconfig와 WebAppConfig에 대한 설정을 포함한다.

<!-- App Config -->
<context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:appConfig.xml</param-value>
</context-param>

<!-- Web Config -->
<servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:webAppConfig.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
</servlet>

/resources/webAppConfig.xml

스프링 웹 계층에 대한 설정을 한다.

<mvc:annotation-driven/>
    <!--<context:annotation-config />-->

<context:component-scan base-package="jpabook.jpashop.web"/>

<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
    <property name="prefix" value="/WEB-INF/jsp/"/>
    <property name="suffix" value=".jsp"/>
</bean>

Tags Descriptions
mvc:annotation-driven 스프링 MVC 기능을 활성화 한다
context:component-scan component scan에 대한 설정으로, 어떤 패키지를 대상으로 component scan을 수행할지를 지정한다.
bean 등록할 Spring Bean 지정, 여기서는 jsp,jstl을 다루는 View Resolver을 등록한다.

/resources/appConfig.xml

스프링 앱에 대한 환경 설정으로, 데이터 계층에 대한 환경을 설정한다.

<tx:annotation-driven/>

<context:component-scan base-package="jpabook.jpashop.service, jpabook.jpashop.repository"/>

<bean id="dataSource" class="org.apache.tomcat.jdbc.pool.DataSource">
    <property name="driverClassName" value="org.h2.Driver"/>
    <property name="url" value="jdbc:h2:mem:jpashop"/>
    <property name="username" value="sa"/>
    <property name="password" value=""/>
</bean>

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

<!-- JPA 예외를 스프링 예외로 변환 -->
<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/>

<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>

Transaction 설정

<tx:annotation-driven/>

annotation 기반의 트랜잭션 관리자를 지정한다., @Transactional 이 설정된 코드에 트랜잭션을 적용시키도록 설정

원래 트랜잭션의 관리자는 DataSourceTransactionManager로 되어 있는데 이를 JPA로 바꿔준다. 트랜잭션 관리자로 설정한다.

DataSource

<bean id="dataSource" class="org.apache.tomcat.jdbc.pool.DataSource">
    <property name="driverClassName" value="org.h2.Driver"/>
    <property name="url" value="jdbc:h2:mem:jpashop"/>
    <property name="username" value="sa"/>
    <property name="password" value=""/>
</bean>

접속하는 DB 정보를 설정한다. jdbc:h2:mem:jpashop 으로 설정하게 되면 IN-Memory 형태의 DB를 이용하게 된다. App을 실행될때 자동으로 DB도 실행된다.

만약, DB 서버로 접속하고자 한다면, jdbc:h2:tcp://localhost/~/jpashop 으로 설정한다.

JPA 예외 변환 AOP 설정

<!-- JPA 예외를 스프링 예외로 변환 -->
<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor"/>

@Repository annotataion이 붙어있는 Spring Brean에 대해서 예외 변환 AOP를 적용한다.

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>

스프링 프레임워크에서 JPA를 사용하려면 LocalContainerEntityManagerFactoryBean을 Spring Bean으로 등록해야한다. Spring-ORM 라이브러리 등록시, LocalContainerEntityManagerFactoryBean 클래스가 제공된다. 이를 통해, 기존의 엔티티 매니저 팩토리 정보는 스프링이 제공하는 방식으로 이용하게 된다. 따라서 persistence.xml 파일은 필요없어진다.

Properties Descriptions
dataSource 사용할 datasource 지정
packagesToScan @entity가 붙은 클래스를 조회하기 위한 시작점 지정
persistenceUnitName 영속성 유닛 이름을 지정한다.
jpaVendorAdapter 사용하고자할 JPA 벤더를 지정
jpaProperties 하이버네이터 구현체를 속성을 지정, 여기서는 H2 DB 관련 설정

Domain 설계

기능 설계

  1. 회원 기능
    • 회원 등록
    • 회원 조회
  2. 상품 기능
    • 상품 등록
    • 상품 수정
    • 상품 조회
  3. 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소

모델 설계

Diagrams

Class Diagram

class_diagram

Erd Diagram

erd_diagram

Entities

위의 diagram들을 바탕으로 아래와 같이 Entity로 나타낼 수 있다.

Member Entity

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    private String name;

    @Embedded
    private Address address;

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

    //gettter and setter
}

Order Entity

@Entity
@Table(name = "ORDERS")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;

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

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<OrderItem>();

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery;  //배송정보

    private Date orderDate;     //주문시간

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

    //==연관관계 메서드==//
    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);
    }
    //gettter and setter
}

Item Entity

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;        //이름
    private int price;          //가격
    private int stockQuantity;  //재고수량

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<Category>();
    //getter and setter
}

Book, Album, Movie Entity (Item’s Child classes)

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
    //getter and setter
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    private String artist;
    private String etc;
    //getter and setter
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;
    private String actor;
    //getter and setter
}

Delivery Entity

@Entity
public class Delivery {

    @Id @GeneratedValue
    @Column(name = "DELIVERY_ID")
    private Long id;

    @OneToOne(mappedBy = "delivery")
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status;

    //getter and setter
}

Category Entity

@Entity
public class Category {

    @Id @GeneratedValue
    @Column(name = "CATEGORY_ID")
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(name = "CATEGORY_ITEM",
            joinColumns = @JoinColumn(name = "CATEGORY_ID"),
            inverseJoinColumns = @JoinColumn(name = "ITEM_ID"))
    private List<Item> items = new ArrayList<Item>();

    //하위 카테고리 항목을 위한 지정 --> Self Relationship 표현
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "PARENT_ID")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<Category>();

    //==연관관계 메서드==//
    public void addChildCategory(Category child) {
        this.child.add(child);
        child.setParent(this);
    }
    //getter and setter
}

Address Embedded Type

@Embeddable
public class Address {

    private String city;
    private String street;
    private String zipcode;

    //getter and setter
}

어플리케이션 구현

web_application_structure

웹 어플리케이션은 위의 구조로 동작한다. 해당 구조를 바탕으로 각각의 기능들을 구현해보자

회원 기능

  • 회원 등록
  • 회원 조회
Repository

Repository는 DB와 직접적으로 연결되어 데이터를 조작한다.

@Repository
public class MemberRepository {

    @PersistenceContext
    EntityManager em;

    public void save(Member member) {
        em.persist(member);
    }

    public Member findOne(Long id) {
        return em.find(Member.class, id);
    }
    //다중 값, 조건에 따른 검색 조회를 위해 jpql사용
    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();
    }
}

@Repository annotation을 명시해서 component scan에 의해 Spring Bean으로 등록되게 된다. 또한 기존의 설정한 예외변환 AOP에 의해서 Spring 기반의 예외 처리를 수행할 수 있다.

@PersistenceContext annotation 설정을 통해, Spring MVC가 자동으로 엔티티 매니저를 주입한다. –> DI라고 볼 수 있다.

@PersistenceUnit을 이용하면 EntityManagerFactory도 받을 수 있다.

각각의 기능들은 JPA와 JPQL을 이용해서 구현 한다.

Service

Service는 Repository를 이용해서 비지니스 로직을 수행한다.

@Service
@Transactional
public class MemberService {

    @Autowired
    MemberRepository memberRepository;

    /**
     * 회원 가입
     */
    public Long join(Member member) {

        validateDuplicateMember(member); //중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }

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

    /**
     * 전체 회원 조회
     */
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Member findOne(Long memberId) {
        return memberRepository.findOne(memberId);
    }
}

@Service annotation으로 Spring Bean으로 등록한다.

@Transactional을 설정하면 해당 서비스를 트랜잭션 내에서 처리될 수 있도록 한다. 메소드 작업이 완료되면 커밋이 호출된다.

모든 데이터는 Repository로 부터 받아와서 service는 비즈니스 로직에만 집중한다.

Test

junit 기반으로 테스트를 수행해보자

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional
public class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    public void 회원가입() throws Exception {

        //Given
        Member member = new Member();
        member.setName("kim");

        //When
        Long saveId = memberService.join(member);

        //Then
        assertEquals(member, memberRepository.findOne(saveId));
    }

    @Test(expected = IllegalStateException.class)
    public void 중복_회원_예외() throws Exception {

        //Given
        Member member1 = new Member();
        member1.setName("kim");

        Member member2 = new Member();
        member2.setName("kim");

        //When
        memberService.join(member1);
        memberService.join(member2); //예외가 발생해야 한다.

        //Then
        fail("예외가 발생해야 한다.");
    }
}

@RunWith(SpringJUnit4ClassRunner.class)은 스프링 프레임워크와 통합된 테스트를 수행할 수 있도록 한다.

@ContextConfiguration(locations = “classpath:appConfig.xml”) 스프링 통합 테스트를 수행할때 적용할 스프링 설정 파일을 지정

각각의 테스트는 given/when/then 구조로 테스트를 설계한다.

  • given: 테스트할 환경
  • when: 테스트 대상 실행
  • then: 결과 검증

Assertions 클래스를 이용해서 값을 검증할 수 있다.

중복회원 테스트의 경우, @Test(expected = IllegalStateException.class)을 통해 해당 테스트를 수행하면 IllegalStateException 에러가 발생할 것을 기대하는데, 해당 에러가 발생하면 테스트가 성공적으로 수행되는 것이다.

만약, 해당 에러가 실행되지 않거나, fail 메소드가 실행되면 테스틑 실패한다.

상품 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정
Repository
@Repository
public class ItemRepository {

    @PersistenceContext
    EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item);
        } else {
            em.merge(item);
        }
    }

    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();
    }
}

위의 save 메소드를 보면 persist와 merge를 혼용해서 사용하고 있는데, 해당 메소드를 통해 저장과 수정의 기능을 동시에 처리하게 된다.

만약, getId가 null이라는 의미는 아직 DB에 저장되지 않은 새로운 엔티티임을 뜻하므로, persist을 만약 식별자값이 존재하는 엔티티였다면,식별자를 가지는 엔티티가 영속성 컨텍스트에서 제거되는 것이므로, 준영속 상태인 엔티티를 다시 merge를 이용해서 병합시킨다.

Service
@Service
@Transactional
public class ItemService {

    @Autowired
    ItemRepository itemRepository;

    public void saveItem(Item item) {
        itemRepository.save(item);
    }

    public List<Item> findItems() {
        return itemRepository.findAll();
    }

    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }
}

주문 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

주문 기능에서는 의미 있는 비즈니스 로직이 있어 이를 확인해보자

Order Entity

//주문 정보를 생산하는 메소드
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(new Date());
    return order;
}

//==비즈니스 로직==//
/** 주문 취소 */
public void cancel() {

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

    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;
}

OrderItem Entity

public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
    OrderItem orderItem = new OrderItem();
    orderItem.setItem(item);
    orderItem.setOrderPrice(orderPrice);
    orderItem.setCount(count);

    item.removeStock(count);
    return orderItem;
}

//==비즈니스 로직==//
/** 주문 취소 */
public void cancel() {
    getItem().addStock(count);
}

//==조회 로직==//
/** 주문상품 전체 가격 조회 */
public int getTotalPrice() {
    return getOrderPrice() * getCount();
}
Repository
@Repository
public class OrderRepository {

    @PersistenceContext
    EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }

    public List<Order> findAll(OrderSearch orderSearch) {
        ...
    }
}

주문 저장, 단일 주문 검색의 경우 간단하지만, 모든 주문을 조회하기 위해서는 연관된 엔티티와 조인이 필요해, JPA만으로는 구현하기 어려워, 아래에서 추가로 설명하겠다.

Service
@Service
@Transactional
public class OrderService {

    @Autowired MemberRepository memberRepository;
    @Autowired OrderRepository orderRepository;
    @Autowired ItemService itemService;

    /** 주문 */
    public Long order(Long memberId, Long itemId, int count) {

        //엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemService.findOne(itemId);

        //배송정보 생성
        Delivery delivery = new Delivery(member.getAddress());
        //주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장
        orderRepository.save(order);
        return order.getId();
    }


    /** 주문 취소 */
    public void cancelOrder(Long orderId) {

        //주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);

        //주문 취소
        order.cancel();
    }

    /** 주문 검색 */
    public List<Order> findOrders(OrderSearch orderSearch) {
        return orderRepository.findAll(orderSearch);
    }
}

주문 정보를 생성할 때는, 배송 정보, 주문 상품 정보가 필요하므로 해당 정보들을 생성해서 respository를 통한 주문 생성 작업을 수행해야한다.

주문을 취소하게 되면, 해당 주문의 상태를 취소상태로 설정하고, Item의 재고를 취소한 만큼 추가해주는 작업을 수행한다.

주문 검색이 위의 두 기능에 비해 복잡한 부분이 존재한다.

주문 검색은 회원과 주문 상태에 따른 검색을 가능하도록 하기 위해 아래의 클래스를 추가적으로 이용한다.

OrderSearch Class

public class OrderSearch {

    private String memberName;      //회원 이름
    private OrderStatus orderStatus;//주문 상태
    //getter and setter
}

검색을 진행할 때, 위의 회원 정보, 주문 상태를 받아서 service의 findAll이 호출되고, repository의 findAll이 실행된다.

OrderRepository 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();
}

List<Predicate>을 이용해서 회원정보, 주문 상태에 따른 동적인 Query를 생성하고 있다.

Test

상품 주문 테스트

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional
//@TransactionConfiguration(defaultRollback = false)
public class OrderServiceTest {

    @PersistenceContext
    EntityManager em;

    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

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

        //Given
        //createMember, createBook 메소드를 이용해서 엔티티를 만들어서 DB에 저장하는 작업 수행
        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());
    }
     private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }

    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setStockQuantity(stockQuantity);
        book.setPrice(price);
        em.persist(book);
        return book;
    }
}

재고 수량 초과 주문 테스트

@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("재고 수량 부족 예외가 발생해야 한다.");
}

재고수량을 초과한 주문에 대해서는 NotEnoughStockException 에러가 발생해야한다.

주문 취소 테스트

@Test
public void 주문취소() {

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

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

    //When
    orderService.cancelOrder(orderId);

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

    assertEquals("주문 취소시 상태는 CANCEL 이다.", OrderStatus.CANCEL, getOrder.getStatus());
    assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
}

Web Application 구현

사용자와 직접적으로 데이터를 주고 받는 View 와 Controller이 Web 파트이다,각각의 기능에 대한 Controller, View을 구현해줘야한다.

여기서는 상품 등록, 수정에서 이용되는 View, Controller에 대해서만 간략하게 알아보자

상품 주문

Controller
@Controller
public class ItemController {

    @Autowired ItemService itemService;

    @RequestMapping(value = "/items/new", method = RequestMethod.GET)
    public String createForm() {
        return "items/createItemForm";
    }

    @RequestMapping(value = "/items/new", method = RequestMethod.POST)
    public String create(Book item) {

        itemService.saveItem(item);
        return "redirect:/items";
    }

    /**
     * 상품 목록
     */
    @RequestMapping(value = "/items", method = RequestMethod.GET)
    public String list(Model model) {

        List<Item> items = itemService.findItems();
        model.addAttribute("items", items);
        return "items/itemList";
    }

}

@Controller로 controller을 설정해준다.

@RequestMapping을 이용해서 각각의 기능에 대한 request URL을 매핑해준다.

이때, 처음에 InternalResourceViewResolver 라는 View Resolver를 등록해는 데, 이는 내부적으로 파일 이름을 보고, 설정한 prefix, suffix을 토대로 view의 물리적 위치를 반환한다. 각각의 controller들은 Model를 이용해서 View와 데이터를 주고 받는다.

자세한 내용은 Spring MVC 프레임워크를 참조하면 된다.

View
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<jsp:include page="../fragments/head.jsp"/>
<body>

<div class="container">
    <jsp:include page="../fragments/bodyHeader.jsp" />

    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>상품명</th>
                <th>가격</th>
                <th>재고수량</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            <!-- 조회된 상품의 정보 조회-->
            <c:forEach items="${items}" var="item">
                <tr>
                    <td>${item.id}</td>
                    <td>${item.name}</td>
                    <td>${item.price}</td>
                    <td>${item.stockQuantity}</td>
                    <td>
                        <a href="/items/${item.id}/edit" class="btn btn-primary" role="button">수정</a>
                    </td>
                </tr>
            </c:forEach>
            </tbody>
        </table>
    </div>

    <jsp:include page="../fragments/footer.jsp" />

</div> <!-- /container -->

</body>
</html>

위의 jsp 파일을 보면 <c:forEach> jstl 태그를 이용해서 동적인 HTML을 구성하는 것을 확인할 수 있다.

상품 수정

Controller
/**
* 상품 수정 폼
*/
@RequestMapping(value = "/items/{itemId}/edit", method = RequestMethod.GET)
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {

    Item item = itemService.findOne(itemId);
    model.addAttribute("item", item);
    return "items/updateItemForm";
}

/**
* 상품 수정
*/
@RequestMapping(value = "/items/{itemId}/edit", method = RequestMethod.POST)
public String updateItem(@ModelAttribute("item") Book item) {

    itemService.saveItem(item);
    return "redirect:/items";
}

상품 정보를 수정해야하므로, 기존 상품 정보를 조회해서 View로 넘겨준다.

View
<form role="form" method="post">
    <!-- id -->
    <input type="hidden" name="id" value="${item.id}">

    <div class="form-group">
        <label for="name">상품명</label>
        <input type="text" name="name" class="form-control" id="name" placeholder="이름을 입력하세요" value="${item.name}">
    </div>
    <div class="form-group">
        <label for="price">가격</label>
        <input type="number" name="price" class="form-control" id="price" placeholder="가격을 입력하세요" value="${item.price}">
    </div>
    <div class="form-group">
        <label for="stockQuantity">수량</label>
        <input type="number" name="stockQuantity" class="form-control" id="stockQuantity" placeholder="수량을 입력하세요" value="${item.stockQuantity}">
    </div>
    <div class="form-group">
        <label for="author">저자</label>
        <input type="text" name="author" class="form-control" id="author" placeholder="저자를 입력하세요" value="${item.author}">
    </div>
    <div class="form-group">
        <label for="isbn">ISBN</label>
        <input type="text" name="isbn" class="form-control" id="isbn" placeholder="ISBN을 입력하세요" value="${item.isbn}">
    </div>
    <button type="submit" class="btn btn-default">Submit</button>
</form>

jsp에서는 model로 전달해준 변수/객체를 이용해서 ${item} 과 같은 방식으로 직접 접근 할 수 있다.

상품 주문

Controller
@RequestMapping(value = "/order", method = RequestMethod.GET)
public String createForm(Model model) {

    List<Member> members = memberService.findMembers();
    List<Item> items = itemService.findItems();

    model.addAttribute("members", members);
    model.addAttribute("items", items);

    return "order/orderForm";
}

@RequestMapping(value = "/order", method = RequestMethod.POST)
public String order(@RequestParam("memberId") Long memberId, @RequestParam("itemId") Long itemId, @RequestParam("count") int count) {

    orderService.order(memberId, itemId, count);
    return "redirect:/orders";
}

상품 주문을 진행할 때, 주문을 진행하는 회원 정보, 주문할 상품을 선택할 수 있어야 하므로 Repository를 이용해서 member, item list을 view에 전달한다.

View
<form role="form" action="/order" method="post">
        <!-- 회원 목록 -->
        <div class="form-group">
            <label for="member">주문회원</label>
            <select name="memberId" id="member" class="form-control">
                <option value="">회원선택</option>
                <c:forEach var="member" items="${members}">
                    <option value="${member.id}">${member.name}</option>
                </c:forEach>
            </select>
        </div>
        <!-- 상품 목록 -->
        <div class="form-group">
            <label for="item">상품명</label>
            <select name="itemId" id="item" class="form-control">
                <option value="">상품선택</option>
                <c:forEach var="item" items="${items}">
                    <option value="${item.id}">${item.name}</option>
                </c:forEach>
            </select>
        </div>

        <div class="form-group">
            <label for="count">주문수량</label>
            <input type="number" name="count" class="form-control" id="count" placeholder="주문 수량을 입력하세요">
        </div>

        <button type="submit" class="btn btn-default">Submit</button>
    </form>

References

book: 자바 ORM 표준 JPA 프로그래밍 -김영한 저

book_link

태그: ,

카테고리:

업데이트:

댓글남기기