업데이트:

Spring JPA1

멋쟁이사자처럼 대학 11기 백엔드 스프링부트 교육 과정 중 인프런 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발을 수강하고 정리한 포스트입니다.

1. 프로젝트 환경설정

프로젝트 생성

Spring Initializr (https://start.spring.io) 를 이용한 프로젝트 생성

  • Project: Gradle
  • Language: Java
  • Spring Boot: 3.0.6 (안정화)
  • Group: jpabook
  • Artifact: jpashop
  • Dependencies:
    • Spring Web Starter
    • Thymeleaf
    • Spring Data JPA
    • H2 Database
    • Lombok

* Lombok: getter, setter 등을 어노테이션으로 만들어주는 라이브러리

build.gradle

plugins {
	id 'java'
	// 스프링부트 플러그인은 라이브러리들에 대한 디펜던시 버전 관리를 해줌
	id 'org.springframework.boot' version '3.0.6'
	id 'io.spring.dependency-management' version '1.1.0'
}

group = 'jpabook'
version = '0.0.1-SNAPSHOT'
// 자바 버전
sourceCompatibility = '17'

configurations {
	compileOnly {
		// 롬복
		extendsFrom annotationProcessor
	}
}

// 라이브러리를 받는 공간
repositories {
	mavenCentral()
}

// 그래들은 기본적으로 의존관계가 필요한 라이브러리들을 전부 연결하여 가져옴
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	//JUnit4 추가
	testImplementation("org.junit.vintage:junit-vintage-engine") {
		exclude group: "org.hamcrest", module: "hamcrest-core"
	}
}

tasks.named('test') {
	useJUnitPlatform()
}

JpashopApplication & JpashopApplicationTest

Spring Initializr가 기본적으로 만들어주는 클래스

@SpringBootApplication
public class JpashopApplication {
	public static void main(String[] args) {
		SpringApplication.run(JpashopApplication.class, args);
	}
}
@SpringBootTest
class JpashopApplicationTests {
	@Test
	void contextLoads() {
	}
}

롬복 플러그인 설치

image

File > Settings > Plugins > Marketplace > Lombok 검색 후 설치

롬복 사용 설정

image

File > Settings > Build, Execution, Deployment > Compiler > Annotation Processors > Enable annotation processing 체크

라이브러리 살펴보기

명령어로 보기

./gradlew dependencies —configuration compileClasspath

의존관계를 트리형태로 보여준다.

IntelliJ로 보기

image

  • spring-boot-starter-webspring-boot-starter-tomcat / spring-webmvc

  • spring-boot-starter-thymeleaf

  • spring-boot-starter-data-jpaspring-boot-starter-aop / spring-boot-starter-jdbc / hibernate / spring-data-jpa
    • spring-boot-starter-jdbcHikariCP 커넥션 풀
      스프링부트 2.0부터 커넥션 풀로 HikariCP를 기본으로 사용한다.
  • (공통) spring-boot-starterspring-boot-starter-logging / spring-boot
    • spring-boot-starter-logginglogback / slf4j
      slf4j는 로그를 찍는 인터페이스의 모음으로 구현체로 logback을 기본으로 사용한다.
    • spring-bootspring-core
  • spring-boot-starter-testjunit / mockito / assertj / spring-test

View 환경 설정

템플릿 엔진으로는 Thymeleaf, Apache Freemarker, Mustache, Groovy Templates 등이 있는데 스프링은 Thymeleaf를 밀고 있는 추세이다.

Thymeleaf는 Natural templates로 HTML 태그안에 문법을 넣어 문제를 해결하여 markup을 깨지않아 웹브라우저에서 열 수 있다.

* 스프링을 공부할 때, https://spring.io/guides 에서 가이드를 참고하면서 공부하면 좋다.

HelloController.java

@Controller
public class HelloController {
    @GetMapping("hello")
    // Model에 데이터를 실어서 Controller에서 Model을 View에 넘김
    public String hello(Model model){
        // name이라는 키로 value값을 넘김
        model.addAttribute("data", "hello");
        // viewName(html 파일 이름) 리턴
        // 스프링부트의 타임리프가 /resources/templates/ 에서 {viewName}.html을 매핑해줌
        return "hello";
    }
}

hello.html

<!DOCTYPE HTML>
<!-- html 태그에 namespace를 thymeleaf로 설정 -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
	<!-- Model로 넘긴 데이터의 키로 값에 접근해 출력 -->
	<!-- p 태그 안 텍스트는 기본 값 (thymeleaf를 거치지 않음) -->
	<p th:text="'안녕하세요. ' + ${data}" >안녕하세요. 손님</p>
</body>
</html>

Server Side Rendering

image

Static Contents

image

resoreces 파일이 변경되면 서버를 재시작해야 한다.

* html 파일 변경 후 recompile해도 화면이 바뀌지 않는다.

build.gradle에 devtools 라이브러리를 추가하면 html 파일만 리컴파일 해주면 변경된 화면을 볼 수 있다.

// devtools: 개발할 때 유용한 기능들이 많은 라이브러리(캐쉬, 리로딩 등)
implementation 'org.springframework.boot:spring-boot-devtools'

H2 데이터베이스 설치

H2 데이터베이스는 개발이나 테스트 용도로 가볍고 편리하며 웹 콘솔 환경을 제공한다.

https://www.h2database.com/html/download-archive.html 에서 1.4.200 버전을 다운로드 한다.

* 자바 17버전 사용시 h2 2.1.214 버전을 설치하는 것을 추천한다. 알 수 없는 오류로 몇 시간을 버렸다…

다운로드 후 C:\Program Files (x86)\H2\bin\h2.bat를 실행하면 웹 콘솔 환경(java)이 실행된다.

image

JDBC URL에 db파일이 저장될 위치(파일 모드)를 지정하고 로그인한다.

image

사용자 폴더 밑에 jpashop.mv.db가 생성되고 화면은 다음과 같다.

image

연결 끊고 난 후 다음부터 접근할 때에는 JDBC URL을 jdbc:h2:tcp://localhost/~/jpashop(네트워크 모드)로 설정하고 로그인한다.

JPA와 DB 설정, 동작확인

application.properties를 지우고 application.yml을 생성한다.
설정파일이 많아지고 복잡해질수록 yml이 편리하다.

application.yml

spring:
  # 데이터베이스 커넥션과 관련된 데이터 소스 설정
  # Hikari CP를 사용해서 커넥션 풀 등을 스프링부트가 알아서 세팅해줌
  datasource:
    # 데이터베이스 접근 url
    # MVCC: 여러 명이 한번에 접근했을 때 좀 더 빨리 처리됨;MVCC=TRUE
    url: jdbc:h2:tcp://localhost/~/jpashop
    username: sa
    password:
    driver-class-name: org.h2.Driver
  # jpa 설정
  # 스프링부트 메뉴얼에서 설정법 확인 가능
  jpa:
    hibernate:
      # create: 애플리케이션 실행 시점에 테이블을 지우고 다시 생성
      ddl-auto: create
    properties:
      hibernate:
        # show_sql: true, System.out에 출력
        format_sql: true

# 로깅 설정
logging:
  # 로그 레벨 설정
  level:
    # debug 모드 시 hibernate가 남기는 모든 sql을 볼 수 있음
    # 로거를 통해 출력
    org.hibernate.SQL: debug
    # 쿼리 파라미터 로그 남기기
    org.hibernate.orm.jdbc.bind: trace #스프링 부트 3.x, hibernate6

회원 엔티티

@Entity
// Lombok: getter와 setter 메서드를 만들어 줌
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String username;
}

회원 리포지토리

@Repository
public class MemberRepository {

    // @PersistenceContext: 스프링부트가 EntityManager를 자동으로 주입해줌
    // spring-data-jpa 라이브러리를 추가하면 자동으로 스프링 빈에 엔티티매니저를 등록
    @PersistenceContext
    private EntityManager em;

    // 커맨드와 쿼리 분리 원칙 -> 저장을 하고나면 가급적이면 사이드 이펙트 효과 때문에 리턴값을 거의 안 만들고 id값으로 조회하기 위해 id값을 리턴함
    public Long save(Member member){
        em.persist(member);
        return member.getId();
    }

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

테스트

File > Settings > Editor > Live Templates 에서 커스텀 템플릿을 만들어 사용할 수 있다.

image

@RunWith(SpringRunner.class)
@SpringBootTest
public class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;

    @Test
    // @Transactional: EntityManager를 통한 모든 데이터 변경은 항상 트랜잭션 안에서 이루어져야 함
    // 이 어노테이션이 없으면 오류 발생, 테스트 케이스에 있으면 테스트가 끝나고 롤백
    @Transactional
    // 롤백을 실행하지 않음
    @Rollback(false)
    public void testMember() throws Exception{
        // given
        Member member = new Member();
        member.setUsername("memberA");

        // when
        Long savedId = memberRepository.save(member);
        Member findMember = memberRepository.find(savedId);

        // then
        Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
        Assertions.assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
        // 영속성 컨텍스트 안에서 id값이 같으면 같은 엔티티로 식별
        Assertions.assertThat(findMember).isEqualTo(member);
    }
}

jar 빌드해서 동작 확인

gradlew clean build // 지우고 재빌드
cd build/libs   // jar 파일이 있는 폴더로 이동
java -jar jpashop-0.0.1-SNAPSHOT.jar // jar 파일 실행

외부 라이브러리 - 쿼리 파라미터 로그 남기기

우선 build.gradle에 다음 라이브러리를 추가한다. https://github.com/gavlyukovskiy/spring-boot-data-source-decorator

// 쿼리 파라미터 로그 외부 라이브러리
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.0'

src/resources/META-INF/spring/ 폴더에 org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일을 만들고 다음과 같이 저장한다.

com.github.gavlyukovskiy.boot.jdbc.decorator.DataSourceDecoratorAutoConfiguration

src/resources/ 폴더에 spy.properties 파일을 만들고 다음과 같이 저장한다.

appender=com.p6spy.engine.spy.appender.Slf4JLogger

그러면 다음과 같은 쿼리 파리미터 로그를 볼 수 있다.

|statement|connection 4|url jdbc:h2:tcp://localhost/~/jpashop
|insert into member (username, id) values (?, ?)
|insert into member (username, id) values ('memberA', 1)

이런 라이브러리들은 개발단계에서 편의성 용도로 사용하고 실제 운영시 성능 저하 우려가 있어서 성능 테스트를 해보고 사용해야 한다.

2. 도메인 분석 설계

요구사항 분석

image

간단한 쇼핑몰 요구사항

  • 회원 기능
    • 회원 등록, 회원 조회
  • 상품 기능
    • 상품 등록, 상품 수정, 상품 조회
  • 주문 기능
    • 상품 주문, 주문 내역 조회, 주문 취소
  • 기타 요구사항
    • 상품은 재고 관리가 필요하다.
    • 상품의 종류는 도서, 음반, 영화가 있다.
    • 상품을 카테고리로 구분할 수 있다.
    • 상품 주문시 배송 정보를 입력할 수 있다.

도메인 모델과 테이블 설계

image

  • 회원-주문
    • 회원은 여러 상품을 주문할 수 있다 → 일대다 관계
  • 주문-상품
    • 회원은 주문할 때 여러 상품을 주문할 수 있고, 상품도 여러 주문에 담길 수 있다 → 다대다 관계
    • 주문과 상품의 다대다 관계를 주문상품을 통해서 일대다, 다대일 관계로 풀어낸다.
      • 주문-주문상품
        • 회원은 주문할때 여러 상품을 주문할 수 있다 → 일대다 관계
      • 상품-주문상품
        • 상품도 여러 주문에 담길 수 있다 → 다대일 관계
  • 주문-배송
    • 한번 주문을 할 때 배송정보를 하나 입력한다 → 일대일 관계
  • 카테고리-상품
    • 상품은 도서, 음반, 영화 타입으로 나눠질 수 있다 → 상속관계
    • 하나의 카테고리에 여러 상품이 들어갈 수 있고 하나의 상품이 여러 카테고리에 들어갈 수 있다 → 다대다 관계

회원 엔티티 분석

image

  • Member
    • 공통 속성: id (pk, long)
    • name: 이름
    • address: 주소, 임베디드 타입 (내장 타입, 값 타입)
    • orders: 주문 리스트
  • Order
    • member: 회원
    • orderItem: 주문상품 리스트
    • delivery: 배송정보
    • orderDate: 주문날짜
    • status: 주문상태 (주문, 취소)
  • OrderItem
    • orderPrice: 주문 금액
    • count: 주문 수량
  • Delivary:
    • order: 주문
    • address: 배송지 주소
    • status: 배송상태
  • Item:
    • name: 이름
    • price: 가격
    • stockQuantity: 재고
    • categories: 포함 카테고리
  • Category
    • Album
      • artist: 아티스트
      • etc: 기타
    • Book
      • author: 저자
      • isbn: isbn
    • Movie
      • director: 감독
      • actor: 배우
    • parent: 부모 카테고리
    • child: 자식
    • items: 상품 리스트

* 카테고리-상품의 다대다 관계는 실무에서 쓰면 안 된다 → 일대다, 다대일 관계로 풀어내야한다

* 회원-주문의 양방향 연관관계는 가능하면 쓰지말고 단방향 연관관계를 쓰는게 좋다.

* 회원과 주문은 동급으로 봐야한다. ‘회원을 통해서 항상 주문이 일어난다’가 아니라 ‘주문을 생성할 때 회원이 필요하다’라고 생각하자. 쿼리에서도 회원의 주문내역을 찾는게 아니라 주문에서 필터링 조건에 회원이 들어가게 된다.

회원 테이블 분석

image

  • member: address(값 타입, 임베디드 타입) 정보를 내려받는다.
  • delivery: member와 같이 address(값 타입, 임베디드 타입) 정보를 내려받는다.
  • item: 상속 관계 매핑의 3가지 방법 중 싱글 테이블 전략을 이용한다.
    • 싱글 테이블 전략: 모든 엔티티를 포함하는 테이블을 만들고 dtype으로 구분한다.
  • orders: member_id(fk), delivery_id(fk)를 가진다.
  • orderItem: order_id(fk)를 가진다.
  • category_item: 관계형 데이터베이스는 다대다 관계를 표현할 수 없어서 중간에 매핑 테이블을 두고 다대다 관계를 일대다, 다대일 관계로 풀어낸다.

연관관계 매핑 분석 및 연관관계 주인 설정

  • 회원-주문: 일대다, 다대일 양방향 관계, member_id를 가진주문이 연관관계 주인
  • 주문상품-주문: 다대일 양방향 관계, order_id를 가진 주문상품이 연관관계 주인
  • 주문상품-상품: 다대일 단방향 관계, item_id를 가진 주문상품이 연관관계 주인
  • 주문-배송: 일대일 단방향 관계, delivery_id를 가진 주문이 연관관계 주인
  • 카테고리-상품: 다대다 관계

* 외래키가 있는 곳을 연관관계의 주인으로 정하는 것이 좋다.

엔티티 클래스 개발

* 실무에서는 가급적이면 getter만 사용하고 setter는 꼭 필요한 경우에만 사용한다.

회원 엔티티

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    // 컬럼명 설정
    @Column(name = "member_id")
    private Long id;

    private String name;

    // @Embedded: 내장 타입을 포함함
    @Embedded
    private Address address;

    // 일대다 관계
    // 양방향 연관관계, order 테이블에 있는 member 필드에 의해서 매핑됨
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

주문 엔티티

@Entity
// 테이블명 설정
// sql의 order by 절과 겹쳐서 orders로 변경
@Table(name = "orders")
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    // 다대일 관계, 지연로딩 설정
    @ManyToOne(fetch = FetchType.LAZY)
    // 외래키 설정, 연관관계 주인
    @JoinColumn(name = "member_id")
    private Member member;

    // 일대다 관계
    // CascadeType.ALL: 컬렉션에 persist를 전파(부모와 같이 진행)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    // 일대일 관계, 지연로딩 설정
    // CascadeType.ALL: 각각 진행하는 persist를 같이 진행하게 해줌
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    // 외래키 설정, 연관관계 주인
    // Delivery 보다 Order에서 접근을 더 많이 함
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    // 주문시간
    private LocalDateTime orderDate;

    // EumType.STRING: Enum 타입 값을 문자열로 지정
    @Enumerated(EnumType.STRING)
    // 주문상태 (ORDER, CANCEL)
    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);
    }
}

주문상태

public enum OrderStatus {
    ORDER, CANCEL
}

주문상품 엔티티

@Entity
@Getter
@Setter
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;
}

상품 엔티티

@Entity
// @Inheritance: 부모클래스에 상속 관계 전략 지정
// InheritanceType.SINGLE_TABLE: 싱글 테이블 전략
@Inheritance(strategy = InheritanceType.SINGLE_TABLE )
// 구분 컬럼명 설정
@DiscriminatorColumn(name = "dtype")
@Getter
@Setter
// 구현체를 가지기 때문에 추상클래스로 작성
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<>();
}

상품-도서 엔티티

@Entity
// dtype 값 지정
@DiscriminatorValue("B")
@Getter
@Setter
public class Book extends Item {
    private String author;
    private String isbn;
}

상품-음반 엔티티

@Entity
// dtype 값 지정
@DiscriminatorValue("A")
@Getter
@Setter
public class Album extends Item {
    private String artist;
    private String etc;
}

상품-영화 엔티티

@Entity
// dtype 값 지정
@DiscriminatorValue("M")
@Getter
@Setter
public class Movie extends Item {
    private String director;
    private String actor;
}

배송 엔티티

@Entity
@Getter
@Setter
public class Delivery {
    @Id
    @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    // 일대일 관계, 지연로딩 설정
    @OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
    private Order order;

    // 내장 타입
    @Embedded
    private Address address;

    // @Enumerated: Enum 타입 지정
    // EnumType.ORDINAL: Enum 타입 값을 숫자로 지정, 중간에 추가 시 순서가 밀려 장애 발생 가능
    // EnumType.STRING: Enum 타입 값을 문자열로 지정
    @Enumerated(EnumType.STRING)
    // 배송상태 (READY, COMP)
    private DeliveryStatus status;
}

배송상태

public enum DeliveryStatus {
    READY, COMP
}

카테고리 엔티티

@Entity
@Getter
@Setter
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<>();

    // 다대일 관계, 지연로딩 설정
    @ManyToOne(fetch = FetchType.LAZY)
    // 외래키 설정, 연관관계 주인
    @JoinColumn(name = "parent_id")
    private Category parent;

    // 일대다 관계
    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();

    // 연관관계 편의 메서드
    public void addChildCategory(Category child) {
        this.child.add(child);
        child.setParent(this);
    }
}

주소 값 타입

// @Embeddable: JPA 내장타입, 어딘가 내장될 수 있음
// @Entity 어노테이션을 작성하지 않아서 테이블로 만들지 않고 pk인 id 필드도 존재하지 않음
@Embeddable
// 값은 변경이 되면 안 돼서 setter를 만들지 않음
@Getter
public class Address {
    private String city;
    private String street;
    private String zipcode;

    // JPA가 리플렉션, 프록시 등의 기술들을 사용하기 위해선 기본 생성자가 있어야 함
    // public으로 만들기보다는 protected로 설정
    protected Address() {

    }

    // 생성시에만 값을 할당
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

엔티티 설계시 주의점

  • 엔티티에서는 가급적이면 setter를 사용하지 말자
    • 변경 포인트가 많아서 유지보수가 어렵다
  • 모든 연관관계는 지연로딩으로 설정해야 한다
    • 즉시로딩(EAGER): 어떤 엔티티를 조회할 때 연관된 엔티티를 한 번에 조회하는 것
    • 즉시로딩은 예측이 어렵고 어떤 sql이 실행될지 추적하기가 어렵다, JPQL을 사용할 때 N+1 문제가 자주 발생한다(연관된 모든 테이블 join 쿼리 생성…)
    • 실무에서 모든 연관관계는 기본적으로 지연로딩(LAZY)으로 설정해야 한다
    • 연관된 엔티티를 조회하려면 fetch join이나 엔티티 그래프 기능을 사용한다
    • OneToOne이나 ManyToOne은 기본적으로 fetch 전략이 즉시로딩(EAGER)이 되므로 직접 지연로딩(LAZY)으로 설정해야 한다
  • 컬렉션은 필드에서 초기화하자
    • 컬렉션은 필드에서 초기화하는 것이 안전하다
    • null 문제에서 안전하다
    • 하이버네이트는 persist로 영속화하는 순간 추적하기 위해서 내장 컬렉션으로 변경한다
    • 가급적이면 컬렉션을 변경하면 안된다
    • 컬렉션을 변경하게 되면 하이버네이트가 원하는 메커니즘으로 동작하지 않을 수 있다

테이블, 컬럼명 생성 전략

하이버네이트는 엔티티의 필드명을 그대로 테이블의 컬럼명으로 사용한다.

3. 애플리케이션 구현 준비

구현 요구사항

image

  • 회원 기능
    • 회원 등록, 회원 조회
  • 상품 기능
    • 상품 등록, 상품 수정, 상품 조회
  • 주문 기능
    • 상품 주문, 주문 내역 조회, 주문 취소
  • 예제를 단순화 하기 위해 다음 기능은 구현X
    • 로그인과 권한 관리X
    • 파라미터 검증과 예외 처리X
    • 상품은 도서만 사용
    • 카테고리는 사용X
    • 배송 정보는 사용X

애플리케이션 아키텍처

image

계층형 아키텍처

  • Controller: 웹 계층
  • Service: 핵심 비즈니스 로직, 트랜잭션 처리
  • Repository: JPA 직접 사용하고 엔티티 매니저를 사용하여 DB에 접근
  • Domain: 엔티티가 모여 있는 계층, 모든 계층에서 사용

패키지 구조

  • jpabook.jpashop
    • domain
    • exception
    • repository
    • service
    • web

개발 순서

서비스, 리포지토리 계층 개발 → 테스트 케이스로 검증 → 웹 계층 개발

4. 회원 도메인 개발

회원 리포지토리 개발

회원 리포지토리 코드

@Repository
@RequiredArgsConstructor
public class MemberRepository {
    // 스프링이 엔티티 매니저를 만들어서 주입해줌
    private final EntityManager em;

    public void save(Member member){
        // 영속성 컨텍스트에 엔티티 객체를 넣음
        // 트랜잭션이 커밋되는 시점에 DB에 반영됨, insert 쿼리 생성
        em.persist(member);
    }

    public Member findOne(Long id){
        // find(엔티티 타입, pk) -> 단건 조회
       return em.find(Member.class, id);
    }

    public List<Member> findAll(){
        // JPQL 작성 -> SQL로 번역
        // SQL: 테이블 대상으로 쿼리를 날림, JPQL: 엔티티 객체를 대상으로 쿼리를 날림
        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();
    }
}

회원 서비스 개발

회원 서비스 코드

@Service
// JPA의 모든 데이터 변경은 트랜잭션 안에서 실행되어야 함
// @Transactional 어노테이션을 클래스 레벨에서 사용하면 public 메서드들은 자동으로 트랜잭션에 들어감
// @Transactional(readOnly = true): 읽기 전용, JPA의 조회 성능을 최적화 함
@Transactional(readOnly = true)
// Lombok - @RequiredArgsConstructor: 모든 final 필드를 가지고 생성자를 만들어줌
@RequiredArgsConstructor
public class MemberService {

    // 스프링이 스프링 빈에 등록된 객체를 자동 주입
    private final MemberRepository memberRepository;

    // 회원 가입
    // @Transactional: 쓰기
    @Transactional
    public Long join(Member member){
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    // 중복 회원 검증 -> 데이터베이스의 unique 제약조건 설정해야 안전
    private void validateDuplicateMember(Member member) {
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if(!findMembers.isEmpty()){
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

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

    // 회원 단건 조회
    public Member findOne(Long memberId){
        return memberRepository.findOne(memberId);
    }
}

회원 기능 테스트

테스트 요구사항

  • 회원가입을 성공해야 한다
  • 회원가입 할 때 같은 이름이 있으면 예외가 발생해야 한다

회원가입 테스트 코드

// JUnit 실행시 스프링이랑 엮어서 실행
@RunWith(SpringRunner.class)
// 스프링부트 통합 테스트, 없으면 @Autowired 실패
@SpringBootTest
// @Transactional 어노테이션이 테스트 케이스에 있으면 테스트 종료시 롤백
@Transactional
public class MemberServiceTest {
    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void 회원가입() throws Exception{
        // given
        Member member = new Member();
        member.setName("kim");

        // when
        // 트랜잭션이 commit 될 때 flush가 되면서 JPA 영속성 컨텍스트에 있는 엔티티 객체가 insert 쿼리가 만들어지면서 DB에서 쿼리가 실행됨
        // 그래서 테스트 중에는 롤백되어 insert 쿼리가 실행되지 않음
        Long savedId = memberService.join(member);

        // then
        assertEquals(member, memberRepository.findOne(savedId));
        // 같은 트랜잭션 안에서 같은 pk 값이 같은 엔티티는 같은 영속성 컨텍스트 안에서 하나로 관리됨
    }

    // @Test(expected): 예상되는 예외 작성
    @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("예외가 발생해야 한다.");
    }
}

테스트 케이스를 위한 설정

운영 환경설정과 테스트 환경설정은 분리시킬 수 있다.

/src/test/resources/application.yml

spring:
  # 데이터베이스 커넥션과 관련된 데이터 소스 설정
  # Hikari CP를 사용해서 커넥션 풀 등을 스프링부트가 알아서 세팅해줌
  datasource:
    # 데이터베이스 접근 url
    # 메모리 모드로 실행
    url: jdbc:h2:mem:test
    username: sa
    password:
    driver-class-name: org.h2.Driver

* 스프링부트는 별도의 DB 설정이 없으면 메모리 모드로 동작한다.

spring:
# 로깅 설정
logging:
  # 로그 레벨 설정
  level:
    # debug 모드 시 hibernate가 남기는 모든 sql을 볼 수 있음
    # 로거를 통해 출력
    org.hibernate.SQL: debug
    # 쿼리 파라미터 로그 남기기
    org.hibernate.orm.jdbc.bind: trace #스프링 부트 3.x, hibernate6

5. 상품 도메인 개발

구현 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

상품 엔티티 개발(비즈니스 로직 추가)

데이터를 가지고 있는 쪽에 비즈니스 로직이 있어야 응집력이 높아진다.

상품 엔티티 코드

@Entity
// @Inheritance: 부모클래스에 상속 관계 전략 지정
// InheritanceType.SINGLE_TABLE: 싱글 테이블 전략
@Inheritance(strategy = InheritanceType.SINGLE_TABLE )
// 구분 컬럼명 설정
@DiscriminatorColumn(name = "dtype")
@Getter
@Setter
// 구현체를 가지기 때문에 추상클래스로 작성
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<>();

    // 비즈니스 로직
    // 도메인 주도 설계, 엔티티 자체가 해결할 수 있는 문제는 엔티티 안에 비즈니스 로직 추가, 응집도 ↑

    // 재고 증가
    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 stock");
        }
        this.stockQuantity -= quantity;
    }
}

예외 추가

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    // 메세지 + 메세지가 발생한 근원적인 예외, exception trace 출력?
    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

상품 리포지토리 개발

상품 리포지토리 코드

@Repository
@RequiredArgsConstructor
public class ItemRepository {
    // 스프링 Data JPA가 엔티티 매니저 주입
    private final EntityManager em;

    public void save(Item item){
        // JPA에 저장하기 전까지 id 값이 없음 -> 신규 등록
        if(item.getId() == null){
            em.persist(item);
        }else {
            // 이미 DB에 등록된 객체 -> update
            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();
    }
}

상품 서비스 개발

상품 서비스 코드

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
    private final ItemRepository itemRepository;

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

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

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

상품 서비스 테스트는 생략한다.

6. 주문 도메인 개발

구현 기능

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

주문, 주문상품 엔티티 개발

주문 엔티티 코드

@Entity
// 테이블명 설정
// sql의 order by 절과 겹쳐서 orders로 변경
@Table(name = "orders")
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    // 다대일 관계, 지연로딩 설정
    @ManyToOne(fetch = FetchType.LAZY)
    // 외래키 설정, 연관관계 주인
    @JoinColumn(name = "member_id")
    private Member member;

    // 일대다 관계
    // CascadeType.ALL: 컬렉션에 persist를 전파(부모와 같이 진행)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    // 일대일 관계, 지연로딩 설정
    // CascadeType.ALL: 각각 진행하는 persist를 같이 진행하게 해줌
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    // 외래키 설정, 연관관계 주인
    // Delivery 보다 Order에서 접근을 더 많이 함
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    // 주문시간
    private LocalDateTime orderDate;

    // EumType.STRING: Enum 타입 값을 문자열로 지정
    @Enumerated(EnumType.STRING)
    // 주문상태 (ORDER, CANCEL)
    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);
    }

    // 생성 메서드
    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){
            // IllegalStateException 발생
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        // 주문상태 변경
        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;
        // 스트림 활용
//        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
    }
}

주문상품 엔티티 코드

@Entity
@Getter
@Setter
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 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
// Lombok: final 필드 생성자
@RequiredArgsConstructor
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;

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

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

    // OrderSearch: 검색용 dto
//    public List<Order> findAll(OrderSearch orderSearch){
//
//    }
}

주문 서비스 개발

주문 서비스 코드

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    // 주문
    @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);

        // 주문 생성
        // createOrder 메서드: Order 객체를 생성하는 static 메서드로 기본 생성자의 사용을 제약하고 객체 생성 로직을 통일화함
        // JPA에서 엔티티의 기본 생성자의 접근제어자 protected 허용 -> 사용시 컴파일 오류
        // Lombok -> @NoArgsConstructor(access = AccessLevel.PROTECTED) : 기본생성자로 직접 객체를 생성하지 못하게 함
        Order order = Order.createOrder(member, delivery, orderItem);

        // 주문 저장
        // cascade = CascadeType.ALL 속성 때문에 order를 저장할 때, delivery와 orderItem이 자동으로 저장됨
        // 참조하는 주인이 유일하고, 라이플 사이클을 동일하게 관리할 때, cascade를 사용
        // 만약 중요한 엔티티이고 다른데서도 사용한다면 별도의 리포지토리를 생성해서 관리를 해줘야함
        orderRepository.save(order);

        return order.getId();
    }

    // 주문 취소
    @Transactional
    public void cancelOrder(Long orderId){
        // 주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);
        // 주문 취소
        // 데이터 변경시 데이터를 update하는 쿼리를 날려야하지만, JPA를 활용하면 엔티티에서 데이터 변경이 일어날 때
        // JPA가 알아서 그 바뀐 변경 포인트들에 대해 더티 체킹(변경내역 감지)이 일어나면서 변경 내역을 update하는 쿼리를 날려줌
        order.cancel();
    }

    // 검색
//    public List<Order> findOrders(OderSearch orderSearch){
//        return orderRepository.findAll(orderSearch);
//    }
}

참고

엔티티의 생성, 조회를 제외한 대부분의 비즈니스 로직이 서비스 계층이 아니라 엔티티에 있다.
엔티티에 핵심 비즈니스 로직이 있고 이걸 서비스 계층이 호출하여 처리하는 개발하는 방식을 도메인 모델 패턴이라 부른다.

반대로 엔티티에 비즈니스 로직이 거의 없고 getter랑 setter만 존재해서 서비스 계층에서 핵심 비즈니스 로직을 처리하는 방식을 트랜잭션 스크립트 패턴이라고 한다.

* JPA나 ORM 등을 사용하면 트랜잭션 스크립트 패턴보다는 도메인 모델 패턴으로 개발을 많이 하게 된다고 한다.

주문 기능 테스트

테스트 요구사항

  • 상품 주문이 성공해야 한다
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다
  • 주문 취소가 성공해야 한다

상품 주문 테스트 코드

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
    @Autowired
    EntityManager em;
    @Autowired
    OrderService orderService;
    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception{
        // given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);
        int orderCount = 2;

        // when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

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

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

    // 테스트용 객체 생성 메서드
    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }
}

재고 수량 초과 테스트 코드

@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception{
    // given
    Member member = createMember();
    Book book = createBook("시골 JPA", 10000, 10);
    int orderCount = 11;

    // when
    orderService.order(member.getId(), book.getId(), orderCount);

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

주문 취소 테스트 코드

@Test
public void 주문취소() throws Exception{
    // given
    Member member = createMember();
    Book 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());
}

주문 검색 기능 개발

주문 검색 기능 개발을 위해서는 동적 쿼리가 만들어져야 한다.

검색 조건 파라미터

@Getter
@Setter
public class OrderSearch {
    // 회원 이름
    private String memberName;
    // 주문 상태 (ORDER, CANCEL)
    private OrderStatus orderStatus;
}

검색을 추가한 주문 리포지토리 코드

@Repository
// Lombok: final 필드 생성자
@RequiredArgsConstructor
public class OrderRepository {
    // 엔티티 매니저 주입
    private final EntityManager em;

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

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

    // OrderSearch: 검색용 dto
    public List<Order> findAll(OrderSearch orderSearch){
        // 동적 쿼리가 아님!
        return em.createQuery("select o from Order o join o.member m " +
                        "where o.status = :status " +
                        "and m.name like :name", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                // 최대 1000건 조회
                .setMaxResults(1000)
                .getResultList();
    }

    // JPQL 동적 쿼리 생성
    public List<Order> findAllByString(OrderSearch orderSearch) {
        // JPQL 문자열
        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";
        }

        // 회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " m.name like :name";
        }

        TypedQuery<Order> query = em.createQuery(jpql, Order.class)
                .setMaxResults(1000); // 최대 1000건

        if (orderSearch.getOrderStatus() != null) {
            query = query.setParameter("status", orderSearch.getOrderStatus());
        }

        if (StringUtils.hasText(orderSearch.getMemberName())) {
            query = query.setParameter("name", orderSearch.getMemberName());
        }

        return query.getResultList();
    }

    // JPA Criteria: JPA가 제공하는 표준 동적 쿼리 빌드 스펙
    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<Order, Member> 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).setMaxResults(1000); // 최대 1000건

        return query.getResultList();
    }
}

코드의 결과로 어떤 sql문이 만들어지는지 컴파일 시점에 정확히 파악이 안 돼서 JPA Criteria의 대안으로 Querydsl이 탄생했다.

Querydsl

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 order.status.eq(statusCond);
}

7. 웹 계층 개발

이제 지금까지 만들어둔 베이스 코드를 기반으로 실제 동작하는 화면을 만들어본다.

홈 화면과 레이아웃

홈 컨트롤러 등록

@Controller
// Lombok -> @Slf4j: log 객체를 생성해줌
@Slf4j
public class HomeController {

    @RequestMapping("/")
    public String home(){
        log.info("home controller");
        return "home";
    }
}

home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<!-- replace: 자주 사용하는 것을 만들어두고 임포트해서 사용 -->
<head th:replace="fragments/header :: header">
    <title>Hello</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div class="container">
    <!-- replace: 자주 사용하는 것을 만들어두고 임포트해서 사용 -->
    <div th:replace="fragments/bodyHeader :: bodyHeader" />

    <div class="jumbotron">
        <h1>HELLO SHOP</h1>
        <p class="lead">회원 기능</p>
        <p>
            <a class="btn btn-lg btn-secondary" href="/members/new">회원 가입</a>
            <a class="btn btn-lg btn-secondary" href="/members">회원 목록</a>
        </p>
        <p class="lead">상품 기능</p>
        <p>
            <a class="btn btn-lg btn-dark" href="/items/new">상품 등록</a>
            <a class="btn btn-lg btn-dark" href="/items">상품 목록</a>
        </p>
        <p class="lead">주문 기능</p>
        <p>
            <a class="btn btn-lg btn-info" href="/order">상품 주문</a>
            <a class="btn btn-lg btn-info" href="/orders">주문 내역</a>
        </p>
    </div>

    <!-- replace: 자주 사용하는 것을 만들어두고 임포트해서 사용 -->
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container --></body>
</html>

fragments/header.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="header">
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrinkto-fit=no">
    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <!-- Custom styles for this template -->
    <link href="/css/jumbotron-narrow.css" rel="stylesheet">
    <title>Hello, world!</title>
</head>

fragments/bodyHeader.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="header" th:fragment="bodyHeader">
    <ul class="nav nav-pills pull-right">
        <li><a href="/">Home</a></li>
    </ul>
    <a href="/"><h3 class="text-muted">HELLO SHOP</h3></a>
</div>

fragments/footer.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="footer" th:fragment="footer">
    <p>&copy; Hello Shop V2</p>
</div>

화면을 심플하게 만들기 위해 thymeleaf의 단순한 Include-style layouts을 사용한다.

image

* 뷰 템플릿 변경사항을 서버 재시작 없이 즉시 반영하는 법: html 파일 build-> Recompile한다.

뷰 리소스 등록 BootStrap

화면을 꾸미기 위해 https://getbootstrap.com/docs/4.3/getting-started/download/에서 bootstrap 4.3버전을 다운로드 하고 압축을 푼 뒤 css와 js 폴더를 /resources/static 폴더에 넣는다.

그러면 다음과 같이 화면이 꾸며지게 된다.

image

UI 정렬을 위해 css폴더에 다음 파일을 추가하자.

jumbotron-narrow.css

/* Space out content a bit */
body {
    padding-top: 20px;
    padding-bottom: 20px;
}
/* Everything but the jumbotron gets side spacing for mobile first views */
.header,
.marketing,
.footer {
    padding-left: 15px;
    padding-right: 15px;
}
/* Custom page header */
.header {
    border-bottom: 1px solid #e5e5e5;
}
/* Make the masthead heading the same height as the navigation */
.header h3 {
    margin-top: 0;
    margin-bottom: 0;
    line-height: 40px;
    padding-bottom: 19px;
}
/* Custom page footer */
.footer {
    padding-top: 19px;
    color: #777; border-top: 1px solid #e5e5e5;
}
/* Customize container */
@media (min-width: 768px) {
    .container {
        max-width: 730px;
    }
}
.container-narrow > hr {
    margin: 30px 0;
}
/* Main marketing message and sign up button */
.jumbotron {
    text-align: center;
    border-bottom: 1px solid #e5e5e5;
}
.jumbotron .btn {
    font-size: 21px;
    padding: 14px 24px;
}
/* Supporting marketing content */
.marketing {
    margin: 40px 0;
}
.marketing p + h4 {
    margin-top: 28px;
}
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 768px) {
    /* Remove the padding we set earlier */
    .header,
    .marketing,
    .footer {
        padding-left: 0;
        padding-right: 0; }
    /* Space out the masthead */
    .header {
        margin-bottom: 30px;
    }
    /* Remove the bottom border on the jumbotron for visual effect */
    .jumbotron {
        border-bottom: 0;
    }
}

그러면 다음과 같이 최종 홈 화면이 보이게 된다.

image

회원 등록

회원 등록 폼 객체

// 회원가입을 위한 폼
@Getter
@Setter
public class MemberForm {
    // 필수 입력, 검증
    @NotEmpty(message = "회원 이름은 필수입니다.")
    private String name;
    
    private String city;
    private String street;
    private String zipcode;
}

회원 등록 컨트롤러

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/members/new")
    public String createForm(Model model){
        // 컨트롤러에서 뷰로 넘어갈때 데이터를 실어서 넘김
        // 빈 폼을 넘김
        model.addAttribute("memberForm", new MemberForm());

        // 회원 등록 화면으로 이동
        return "members/createMemberForm";
    }

    @PostMapping("/members/new")
    // @Vaild: 어노테이션이 달린 필드(MemberForm)를 검증을 해줌
    // BindingResult: 발생된 오류가 담기는 객체, 결과가 화면까지 전달됨
    public String create(@Valid MemberForm form, BindingResult result){
        // 오류 처리
        if(result.hasErrors()){
            return "members/createMemberForm";
        }

        // 주소 생성
        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());

        // 멤버 생성
        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);

        // 멤버 등록
        memberService.join(member);

        // 홈 화면으로 이동
        return "redirect:/";
    }
}

회원 등록 폼 화면

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<style>
    .fieldError {
        border-color: #bd2130;
    }
</style>
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <!-- submit 버튼을 누르면 '/members/new'로 post 요청이 전송됨-->
    <form role="form" action="/members/new" th:object="${memberForm}" method="post">
        <div class="form-group">
            <label th:for="name">이름</label>
            <!-- *{name} => memberForm 객체의 name 필드를 가져옴 -->
            <!-- #fields.hasErrors() => 입력 받은 필드의 검증 오류를 불린으로 반환함 -->
            <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
                   th:class="${#fields.hasErrors('name')} ? 'form-control fieldError' : 'form-control'">
            <!-- th:errors => 필드에 대해 오류 메세지를 출력함 -->
            <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect date</p>
        </div>
        <div class="form-group">
            <label th:for="city">도시</label>
            <!-- th:field => thymeleaf가 태그의 id와 name 속성을 한꺼번에 만들어줌-->
            <input type="text" th:field="*{city}" class="form-control" placeholder="도시를 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="street">거리</label>
            <input type="text" th:field="*{street}" class="form-control" placeholder="거리를 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="zipcode">우편번호</label>
            <input type="text" th:field="*{zipcode}" class="form-control" placeholder="우편번호를 입력하세요">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <br/>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

다음과 같이 홈 화면에서 회원 등록을 누르면 회원 등록 화면을 볼 수 있다.

image

만약 검증 필드를 입력하지 않으면 다음과 같이 에러메세지가 나온다.

image

* 엔티티 객체로 폼 입력을 받지 않는 이유는 입력 받지 않는 필드가 있거나 폼으로 받는 데이터와 엔티티의 검증 로직의 차이가 있을 수 있기 때문이다.

회원 목록 조회

회원 목록 컨트롤러 추가

@GetMapping("/members")
public String list(Model model){
    List<Member> members = memberService.findMembers();
    model.addAttribute("members", members);
    return "members/memberList";
}

회원 목록 뷰

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader" />
    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>이름</th>
                <th>도시</th>
                <th>주소</th>
                <th>우편번호</th>
            </tr>
            </thead>
            <tbody>
            <!-- th:each => 리스트를 변수에 바인딩해서 넣어주고 루프를 돌림 -->
            <tr th:each="member : ${members}">
                <td th:text="${member.id}"></td>
                <td th:text="${member.name}"></td>
                <!-- ? => 값이 null이면 이후는 무시함 -->
                <td th:text="${member.address?.city}"></td>
                <td th:text="${member.address?.street}"></td>
                <td th:text="${member.address?.zipcode}"></td>
            </tr>
            </tbody> </table>
    </div>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

회원을 등록하고 홈 화면에서 회원 목록을 누르면 다음과 같은 화면을 볼 수 있다.

image

* 엔티티를 입력 폼으로 사용하게 되면 엔티티에 화면을 처리하는 로직이 추가가 되어 엔티티가 지저분해지고 그러면 유지보수하기 어려워진다. 그래서 엔티티는 최대한 순수하게 외부 dependency 없이 핵심 비즈니스 로직만 포함하도록 해야한다.

* 엔티티를 직접 사용하기보다 DTO로 변환해서 화면에 꼭 필요한 데이터들만 전달하는 것을 권장한다.
템플릿 엔진을 사용할 때는 서버 안에서 html 파일이 렌더링되기 때문에 사용해도 괜찮지만, API를 만들 때는 절대 엔티티를 외부에 넘기면 안된다. API에서 결과로 엔티티를 넘기게되면 엔티티 내부의 은닉 정보가 전달될 수 있고 엔티티 변경시 API의 스펙이 변해버릴 수 있기 때문이다.

상품 등록

상품 등록 폼

@Getter
@Setter
public class BookForm {
    // 상품 수정을 위한 id값
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    private String author;
    private String isbn;
}

상품 등록 컨트롤러

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.service.ItemService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemService itemService;

    @GetMapping("/items/new")
    public String createForm(Model model){
        model.addAttribute("form", new BookForm());
        return "items/createItemForm";
    }

    @PostMapping("/items/new")
    public String create(BookForm form){
        Book book = new Book();
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(book.getAuthor());
        book.setIsbn(book.getIsbn());

        itemService.saveItem(book);

        return "redirect:/";
    }
}

상품 등록 뷰

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <form th:action="@{/items/new}" th:object="${form}" method="post">
        <div class="form-group">
            <label th:for="name">상품명</label>
            <input type="text" th:field="*{name}" class="form-control"
                   placeholder="이름을 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="price">가격</label>
            <input type="number" th:field="*{price}" class="form-control"
                   placeholder="가격을 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="stockQuantity">수량</label>
            <input type="number" th:field="*{stockQuantity}" class="formcontrol"
                   placeholder="수량을 입력하세요">
        </div>
        <div class="form-group"> <label th:for="author">저자</label>
            <input type="text" th:field="*{author}" class="form-control"
                   placeholder="저자를 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="isbn">ISBN</label>
            <input type="text" th:field="*{isbn}" class="form-control"
                   placeholder="ISBN을 입력하세요">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <br/>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

다음과 같이 홈 화면에서 상품 등록을 누르면 회원 등록 화면을 볼 수 있다.

image

상품 목록

상품 목록 컨트롤러

@GetMapping("/items")
public String list(Model model){
    List<Item> items = itemService.findItems();
    model.addAttribute("items", items);
    return "items/itemList";
}

상품 목록 뷰

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <div>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>상품명</th>
                <th>가격</th>
                <th>재고수량</th>
                <th></th>
            </tr>
            </thead>
            <tbody> <tr th:each="item : ${items}">
                <td th:text="${item.id}"></td>
                <td th:text="${item.name}"></td>
                <td th:text="${item.price}"></td>
                <td th:text="${item.stockQuantity}"></td>
                <td>
                    <a href="#" th:href="@{/items/{id}/edit (id=${item.id})}"
                       class="btn btn-primary" role="button">수정</a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
    <div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
</html>

상품을 등록하고 홈 화면에서 상품 목록을 누르면 다음과 같은 화면을 볼 수 있다.

image

상품 수정

상품 목록과 관련된 컨트롤러 코드

@GetMapping("/items/{itemId}/edit")
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model){
    // 예제를 단순화 하기위해 다운캐스팅
    Book item = (Book) itemService.findItem(itemId);

    BookForm form = new BookForm();
    form.setId(item.getId());
    form.setName(item.getName());
    form.setPrice(item.getPrice());
    form.setStockQuantity(item.getStockQuantity());
    form.setAuthor(item.getAuthor());
    form.setIsbn(item.getIsbn());

    model.addAttribute("form", form);

    return "items/updateItemForm";
}

@PostMapping("/items/{itemId}/edit")
// @ModelAttribute: 요청 파라미터를 받아서 객체 안에 넣어줌, 생략가능
public String updateItem(@PathVariable("itemId") String itemId, @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(book.getAuthor());
    book.setIsbn(book.getIsbn());

    itemService.saveItem(book);

    return "redirect:/items";
}

상품 수정 폼 화면

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <form th:object="${form}" method="post">
        <!-- id -->
        <input type="hidden" th:field="*{id}" />
        <div class="form-group">
            <label th:for="name">상품명</label>
            <input type="text" th:field="*{name}" class="form-control"
                   placeholder="이름을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="price">가격</label>
            <input type="number" th:field="*{price}" class="form-control"
                   placeholder="가격을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="stockQuantity">수량</label>
            <input type="number" th:field="*{stockQuantity}" class="form-control" placeholder="수량을 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="author">저자</label>
            <input type="text" th:field="*{author}" class="form-control"
                   placeholder="저자를 입력하세요" />
        </div>
        <div class="form-group">
            <label th:for="isbn">ISBN</label>
            <input type="text" th:field="*{isbn}" class="form-control"
                   placeholder="ISBN을 입력하세요" />
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

상품을 등록하고 홈 화면에서 상품 목록을 누르면 다음과 같은 화면을 볼 수 있다.

image

상품 목록에서 상품을 수정하면 다음과 같이 상품 수정 화면이 나오고 제출을 하게되면 바뀐 상품 목록을 볼 수 있다.

image

image

변경 감지와 병합(merge)

변경 감지 코드

@RunWith(SpringRunner.class)
@SpringBootTest
public class ItemUpdateTest {
    @Autowired
    EntityManager em;

    @Transactional
    public void updateTest() throws Exception{
        Book book = em.find(Book.class, 1L);

        // TX
        // 트랜잭션 안에서 값 변경이 일어남
        book.setName("spring");

        // 변경 감지 == dirty checking
        // TX commit
        // 트랜잭션 커밋 이후 JPA가 변경사항에 대해서 update 쿼리를 자동으로 생성해서 DB에 반영해줌
    }
}

준영속 엔티티는 JPA가 관리하는 영속 상태의 엔티티가 아니어서 변경감지가 일어나지 않는다.

* 준영속 엔티티는 JPA의 영속성 컨텍스트에서 더이상 관리되지 않는 엔티티를 말한다.

준영속 엔티티를 수정하는 방법

  • 변경 감지 기능 사용

엔티티가 영속상태로 관리될 때, 엔티티의 값이 바뀌면 JPA가 트랜잭션 커밋 시점에 변경된 내용을 알아서 DB에 반영해준다.

  • 병합(merge) 사용

image

  1. merge()가 실행되면 파라미터로 넘어온 준영속 엔티티의 식별자 id 값으로 1차 캐시에서 조회한다.
  2. 1차 캐시에 엔티티가 없다면 데이터베이스에서 엔티티를 조회하고 1차 캐시에 저장한다.
  3. 조회한 영속 엔티티(mergeMember)에 준영속 엔티티(member)의 값을 채워 넣는다.
  4. 영속 상태인 mergeMember를 반환한다.

이때 member는 준영속상태로, 영속상태로 관리되지 않으며 merge()의 결과로 반환된 mergeMember가 영속성 컨텍스트에서 관리된다.

* 변경 감지를 이용하면 원하는 속성만 값을 변경할 수 있지만 병합(merge)을 사용하면 모든 속성이 파라미터로 넘어온 값으로 교체되어버린다. 병합시 파라미터로 넘어온 값이 없으면 null로 업데이트되어 위험하다!

ItemService

// item 업데이트시 변경감지를 이용!
// merge()와 작동 방식이 같음, merge()는 변경된 객체를 리턴해줌
@Transactional
public void updateItem(Long id, String name, int price, int stockQuantity){
    // id를 기반으로 DB에 있는 영속상태의 엔티티를 가져옴
    // 트랜잭션 안에서 엔티티를 조회해야 영속상태로 관리됨
    Item findItem = itemRepository.findOne(id);
    // 값 변경
    // setter를 이용해 값을 변경하기 보다는 별도의 의미있는 메서드를 만들어서 변경지점을 추적할 수 있게 해야함
    findItem.setName(name);
    findItem.setPrice(price);
    findItem.setStockQuantity(stockQuantity);

    // 트랜잭션이 커밋되는 시점에 JPA가 flush를 날려서 영속성 컨텍스트에서 변경된 엔티티를 찾고 변경된 값들에 대해 update 쿼리를 DB에 날림
    // 변경감지가 일어나 merge()를 하지 않아도 됨
//        itemRepository.save(findItem);
}

merge를 사용하지말고 변경감지를 활용하자!

ItemCotroller

@PostMapping("/items/{itemId}/edit")
// @ModelAttribute: 요청 파라미터를 받아서 객체 안에 넣어줌, 생략가능
public String updateItem(@PathVariable("itemId") Long itemId, @ModelAttribute("form") BookForm form){
    // 실무에서는 유저가 아이템에 대해 수정할 권한을 체크해주는 로직이 있어야 한다.
    // 컨트롤러에서 엔티티를 생성헤서 파라미터로 전달하기보다는 form에서 필요한 값을 직접 전달해서 값을 변경하자
    // 전달할 데이터가 많다면 dto를 사용하자
//        Book book = new Book();

    // BookForm을 통해서 데이터가 넘어오지만 id 값이 세팅되어있음 = JPA가 관리했던 객체
    // 데이터베이스에 존재하는, 식별자 id 값이 있는 객체를 준영속 상태라고 부름
    // 여기서 book이 준영속 엔티티이고 new 연산자로 생성한 객체임
    // 트랜잭션 안에 있다고 해도 JPA가 관리하지 않아서 update를 할 수 없음
//        book.setId(form.getId());
//        book.setName(form.getName());
//        book.setPrice(form.getPrice());
//        book.setStockQuantity(form.getStockQuantity());
//        book.setAuthor(book.getAuthor());
//        book.setIsbn(book.getIsbn());

//        itemService.saveItem(book);
    // 변경감지를 이용해서 값을 업데이트함
    itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());

    return "redirect:/items";
}

컨트롤러에서 엔티티를 직접 생성하지 말고 트랜잭션이 있는 서비스 계층에 식별자 id와 변경할 데이터를 전달하여 서비스 계층에서 영속 상태의 엔티티를 조회하고 값을 변경하게 하자. 그러면 트랜잭션 커밋 시점에 변경 감지가 실행된다.

또한 setter 메서드를 사용하여 직접 값을 변경하지 말고 엔티티(도메인) 안에서 바로 추적할 수 있는 메서드를 이용하여 값을 변경하자. 그래야 값이 어디서 변경되었는지 확인 가능하다.

상품 주문

상품 주문 컨트롤러

@Controller
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;
    private final MemberService memberService;
    private final ItemService itemService;

    @GetMapping("/order")
    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";
    }

    @PostMapping("/order")
    // @RequestaParam: form 태그의 name 속성으로 value 값이 넘어오는 것을 변수에 바인딩해줌
    public String createOrder(@RequestParam("memberId") Long memberId,
                              @RequestParam("itemId") Long itemId,
                              @RequestParam int count){
        // 주문 생성 
        orderService.order(memberId, itemId, count);

        // 주문 내역 목록 화면으로 이동
        return "redirect:/orders";
    }
}

컨트롤러에서 member와 item을 조회해서 서비스 계층에 넘겨도 되지만 보통 그렇게 하지않는다.
컨트롤러에서는 트랜잭션 안에서 엔티티 조회가 되지 않아 엔티티가 영속상태로 관리되지 않는다.
그래서 엔티티의 값이 변경되어도 변경감지가 일어나지 않아서 유지보수하기 어렵다.
그래서 컨트롤러에서는 식별자값만 넘기도록 로직을 단순화한다.

서비스 계층에서는 컨트롤러에서 넘어온 식별자 값으로 엔티티를 조회하는 것을 시작으로 핵심 비즈니스 로직을 구현한다. 그러면 트랜잭션 안에서 엔티티 조회가 되어 엔티티가 영속상태로 관리가 되고 값이 변경되어도 변경감지가 일어날 수 있게 된다.

상품 주문 폼

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <!-- submit 버튼 클릭시 /order로 post 요청 전송 -->
    <form role="form" action="/order" method="post">
        <div class="form-group">
            <label for="member">주문회원</label>
            <!-- select 태그로 회원 선택 -->
            <select name="memberId" id="member" class="form-control">
                <option value="">회원선택</option>
                <!-- th:each로 멤버 리스트를 받아서 option 태그 안에서 루프 -->
                <option th:each="member : ${members}"
                        th:value="${member.id}"
                        th:text="${member.name}" />
            </select>
        </div>
        <div class="form-group">
            <label for="item">상품명</label>
            <select name="itemId" id="item" class="form-control">
                <option value="">상품선택</option>
                <option th:each="item : ${items}"
                        th:value="${item.id}"
                        th:text="${item.name}" />
            </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-primary">Submit</button>
    </form>
    <br/>
    <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

회원가입을 하고 상품을 등록한 뒤, 홈 화면에서 상품 주문을 누르면 다음과 같은 화면을 볼 수 있다.

image

다음 화면처럼 회원과 상품을 선택하고 주문 수량을 입력한 뒤 Submit 버튼을 누르면 주문 내역 화면으로 넘어가게 된다.

image

주문 목록 검색, 취소

주문 목록 검색 컨트롤러

@GetMapping("/orders")
// OrderSearch: 검색 조건을 받는 객체(회원 이름, 주문 상태)
// @ModelAttribute: thymeleaf에서 form 태그의 object 값을 받음, 자동으로 model에 담겨짐
// form이 submit될 때, orderSearch로 get 방식으로 값이 넘어와 orderSerch에 값이 바인딩 됨
// 그 객체로 검색을 하고 그 결과를 order/orderList로 값을 전달함
public String orderList(@ModelAttribute("orderSearch") OrderSearch orderSearch, Model model){
    List<Order> orders = orderService.findOrders(orderSearch);
    model.addAttribute("orders", orders);

    // 생략된 코드
//        model.addAttribute("orderSearch", orderSearch);

    return "order/orderList";
}

주문 목록 검색 폼

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header"/>
<body>
<div class="container">
    <div th:replace="fragments/bodyHeader :: bodyHeader"/>
    <div>
        <div>
            <!-- 검색 조건 폼 -->
            <form th:object="${orderSearch}" class="form-inline">
                <div class="form-group mb-2">
                    <input type="text" th:field="*{memberName}" class="formcontrol"
                           placeholder="회원명"/>
                </div>
                <div class="form-group mx-sm-1 mb-2">
                    <select th:field="*{orderStatus}" class="form-control">
                        <option value="">주문상태</option>
                        <!-- enum 타입의 OrderStatus의 values()를 호출해서 status를 루프 -->
                        <option th:each="status : ${T(jpabook.jpashop.domain.OrderStatus).values()}"
                                th:value="${status}"
                                th:text="${status}">option
                        </option>
                    </select>
                </div>
                <button type="submit" class="btn btn-primary mb-2">검색</button>
            </form>
        </div>
        <table class="table table-striped">
            <thead>
            <tr> <th>#</th>
                <th>회원명</th>
                <th>대표상품 이름</th>
                <th>대표상품 주문가격</th>
                <th>대표상품 주문수량</th>
                <th>상태</th>
                <th>일시</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            <!-- 컨트롤러에서 orders를 받아 루프 -->
            <tr th:each="order : ${orders}">
                <td th:text="${order.id}"></td>
                <td th:text="${order.member.name}"></td>
                <td th:text="${order.orderItems[0].item.name}"></td>
                <td th:text="${order.orderItems[0].orderPrice}"></td>
                <td th:text="${order.orderItems[0].count}"></td>
                <td th:text="${order.status}"></td>
                <td th:text="${order.orderDate}"></td>
                <td>
                    <!-- 상품의 상태가 ORDER일 경우에만 버튼 노출 -->
                    <!-- 버튼 클릭시 js로 작성한 cancle()함수가 호출됨 -->
                    <a th:if="${order.status.name() == 'ORDER'}" href="#"
                       th:href="'javascript:cancel('+${order.id}+')'"
                       class="btn btn-danger">CANCEL</a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
    <div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
<script>
    function cancel(id) {
        // form 생성
        var form = document.createElement("form");
        // post 방식으로 /orders/{orderId}/cancel로 요청
        form.setAttribute("method", "post"); form.setAttribute("action", "/orders/" + id + "/cancel");
        document.body.appendChild(form);
        form.submit();
    }
</script>
</html>

주문 취소

@PostMapping("orders/{orderId}/cancel")
public String cancleOrder(@PathVariable("orderId") Long orderId){
    // 주문 취소
    orderService.cancelOrder(orderId);
    // 주문 내역으로 이동
    return "redirect:/orders";
}

주문을 등록하면 다음과 같이 주문 내역을 볼 수 있고 회원명과 주문상태로 검색도 가능하다.

image

CANCEL 버튼을 누르면 주문상태가 CANCEL로 바뀌게 된다.

image

test 회원이 상품을 하나 더 주문하고 주문 내역 화면에서 회원명을 ‘test’로 검색을 하면 다음과 같은 주문 내역 결과를 볼 수 있다.

image

주소창을 보면 memberName과 orderStatus가 쿼리 파라미터로 넘어가 검색을 하게된다.

회원 이름만 넘겨서 ORDER 상태와 CANCEL 상태 둘다 볼 수 있다.

test 회원이 10개를 주문하고 취소한 다음 12개를 다시 주문했으므로 상품 목록 화면에서 JPA의 재고는 88개로 나오는 것을 볼 수 있다.

image

참조

태그:

카테고리:

업데이트:

댓글남기기