이후 섹션들은 라이브 코딩을 따라하면서 회원, 상품, 주문 도메인과 웹 계층을 개발하기 때문에 강의 중 새롭게 학습한 개념이나 실무에 적용할 수 있는 팁들만 작성하려고 한다.
1. 도메인 개발
화면(웹 계층) 개발은 뒤로 미루고 핵심 비즈니스 로직부터 개발한다.
리포지토리 - EntityManager
@PersistentContext 애노테이션을 사용하면 스프링이 알아서 SpringEntityManger를 주입해 준다. @Autowired 애노테이션을 적어도 가능하다.
em.persist() | 영속성 컨텍스트에 저장, 트랜잭션이 커밋되는 시점에 DB에 반영(INSERT), Key:Value=PK:Entity |
em.find() | 타입과 PK를 파라미터로 받는 조회 메서드 |
em.createQuery() | JPQL과 타입을 넣어 쿼리를 작성 가능, from의 대상이 테이블 대신 엔티티 객체임, 파라미터 바인딩 가능 |
리포지토리 - JPQL 쿼리 작성하기
SQL과는 조금 다르다. SQL은 테이블을 대상으로 쿼리를 작성하지만, JPQL은 엔티티 객체를 대상으로 쿼리를 작성한다. 아래 findByName 메서드처럼 쿼리 안에 파라미터를 넣을 수도 있다.
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();
}
서비스 - @Transactional
JPA의 모든 데이터 변경이나 로직은 가급적이면 트랜잭션 안에서 실행돼야 한다. class 레벨에 @Transactional 애노테이션을 붙이면 된다.
- readOnly 속성을 true로 설정하면 읽기 전용으로 실행되기 때문에 데이터의 변경이 없다고 판단하며 리소스를 적게 사용한다. 따라서 영속성 컨텍스트를 flush하지 않기 때문에 성능을 약간 향상할 수 있다. (기본값은 false다)
- 조회 메서드엔 @Transactional(readOnly = true)를 붙이고, 조회를 제외한 메서드에선 @Transactional만 사용하자.
모든 애노테이션은 class 레벨보다 메서드 레벨이 우선권을 가진다.
- 따라서 class 레벨에 읽기 전용으로 애노테이션을 적었다면, 조회가 아닌 나머지 메서드엔 @Transactional 애노테이션을 따로 작성해줘야 한다.
서비스 - 검증 로직
실무에서는 검증 로직(ex. 회원 이름 중복)이 있어도 여러 개의 WAS를 동시에 띄우는 등의 멀티 쓰레드 상황을 고려해서 회원 이름 컬럼에 UNIQUE 제약 조건을 추가하는 것이 안전하다.
- 검증 로직 + DB 제약 조건 설정
핵심 비즈니스 로직은 가장 가깝고, 관련 있는 엔티티에 작성하는 것이 좋다.
- 서비스가 단순히 리포지토리를 위임만 하는 class인 경우, Controller 단에서 리포지토리에 바로 접근하도록 구현해도 크게 문제가 없다.
참고
파라미터에 'OrderItem... orderItems'처럼 사용하면 OrderItem을 리스트처럼 여러 개 받아 오겠다는 의미다.
엔티티 - 생성 메서드
엔티티에 Setter를 적용하지 않고, 생성 메서드를 통해 한 번에 연관된 모든 필드를 설정할 수 있도록 설계하는 게 좋다.
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;
}
이렇게 메서드를 구현해 놔도 다른 개발자는 new Order(); setMember(); 같은 방식을 사용해서 개발할 수 있다. 두 방식이 섞이게 되면 유지 보수하기 어려워지기 때문에 엔티티 class 레벨에 @NoArgsConstructors(access = AccessLevel.PROTECTED) 애노테이션을 붙여 파라미터가 없는 생성자를 쓰지 못하게 막아버리면 좋다.
@Entity
@Getter @Setter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
...
}
엔티티 - 도메인 모델 패턴
OrderService의 order()와 cancel() 메서드를 보면 비즈니스 로직이 대부분 엔티티에 몰려있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
- 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 한다.
- JPA 같은 ORM들을 쓰면 주로 사용하는 방식이다.
- 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.
- SQL을 다룰 때 주로 사용하는 방식이다.
- 내가 지금까지 해본 프로젝트는 모두 이 방식을 사용했다.
정답이라고 할 건 없고, 상황에 따라 유지 보수하기 쉬운 패턴을 선택하면 된다. (두 패턴이 한 프로젝트에서 양립하기도 한다.)
테스트 - 기술 설명
@RunWith(SpringRunner.class)
- 스프링 테스트 통합
@SpringBootTest
- 스프링 부트를 띄우고 테스트
- 작성하지 않으면 스프링 부트를 띄우지 않기 때문에 @Autowired가 다 실패함
참고
테스트할 때 DB나 Spring과 관계없이 순수하게 메서드(ex. removeStock(), addStock())마다 각각 유닛 테스트를 진행하는 것이 좋지만, JPA에 대한 학습을 하는 강의이기 때문에 통합 테스트로 진행한다.
@Transactional
- 반복 가능한 테스트 지원
- 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고, 테스트가 끝나면 강제로 롤백(테스트 케이스에서 사용될 때만)
@Rollback(false)
- 영속성 컨텍스트에 있는 객체가 DB에 저장되려면 트랜잭션이 커밋돼야 함
- 이 애노테이션을 작성하면 트랜잭션 강제 롤백이 취소되고 커밋되므로 DB에 객체가 저장됨
@Test(expected = .class)
- 테스트 시 발생해야 할 예외 class를 적어 테스트할 수 있음
fail() 메서드
- 이 메서드에 도달해서는 안 된다는 표시라고 할 수 있음
참고
테스트 케이스는 Given - When - Then 순서로 작성해 보자.
테스트 - 테스트 케이스를 위한 설정
테스트는 케이스가 격리된 상황에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다. 따라서 진짜 DB보단 메모리 DB를 사용하는 것이 가장 이상적이다.
- 테스트 케이스를 위한 스프링 환경과 일반적으로 애플리케이션을 실행하는 환경은 보통 다르므로 설정 파일을 분리하는 것이 좋다. 다음과 같이 test/resources/application.yml 위치에 간단하게 테스트용 설정 파일을 추가하면 된다.
- 이 위치에 파일이 없다면 src/resources/application.yml을 읽는다.
spring:
# datasource:
# url: jdbc:h2:tcp://localhost/~/jpashop
# username: sa
# password:
# driver-class-name: org.h2.Driver
# jpa:
# hibernate:
# ddl-auto: create
# properties:
# hibernate:
# # show_sql: true
# format_sql: true
logging.level:
org.hibernate.orm.jdbc.bind: trace
스프링 부트는 datasource 설정이 따로 없으면 기본적으론 메모리 DB(ex. jdbc:h2:mem:test)를 사용하며 driver-class도 현재 등록된 라이브러리를 보고 찾아준다.
- ddl-auto도 create-drop 모드(create가 끝나고 drop 쿼리는 날려버림)로 동작한다. 따라서 datasource나 JPA 관련 별도의 추가 설정을 하지 않아도 된다.
JPA에서 동적 쿼리 해결하기
주문 내역에서 회원명과 주문상태로 주문 내역을 검색할 수 있다. orderSearch에 있는 두 값(orderStatus, memberName)이 모두 NULL로 들어오지 않는다면 아래 코드처럼 구현하면 된다. 그러나 NULL로 들어오는 경우도 존재하기 때문에 선택한 파라미터에 맞춰 결과를 반환할 수 있는 동적 쿼리를 사용해야 한다.
public List<Order> findAll(OrderSearch orderSearch) {
// orderSearch의 값이 다 null로 들어오지 않는다면 아래 JPQL을 사용해도 됨
// 그러나 null로 들어오는 경우도 존재하기 때문에 동적 쿼리를 사용해야 함
return em.createQuery("select o from Order o join o.member m"
+ " where o.status = :status"
+ " and m.name = :name", Order.class)
.setParameter("status", orderSearch.getOrderStatus())
.setParameter("name", orderSearch.getMemberName())
.setMaxResults(1000)
.getResultList();
}
a. JPQL
JPQL로 동적 쿼리를 처리하는 방식을 사용하면, 쿼리를 FROM 절까지 String으로 작성해 두고 두 파라미터 값이 NULL인지에 따라 뒤에 WHERE 절을 String으로 붙이면서 진행한다. 쿼리가 완성됐다면 파라미터 값까지 설정하고 getResultList()로 반환하면 된다.
- 간단해 보이지만 메서드 하나에 20줄 정도 적어야 한다.
- JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 발생하기 쉬워지기 때문에 추천하지 않는다.
b. JPA Criteria
JPA Criteria로 처리하는 방식도 있다. JPA Criteria는 JPQL을 자바 코드로 작성할 수 있게 해주는 JPA 표준 스펙이지만, 실무에서 사용하기엔 너무 복잡하다.
- JPQL로 작성하는 것보단 편해졌지만, 유지 보수하기 어려워서 실무에서 거의 사용하지 않는다.
가장 멋진 해결책은 Querydsl이 제시했지만, 다른 강의에서 자세히 다루기 때문에 지금은 이대로 진행한다.
2. 웹 계층 개발
Thymeleaf 참고
a. 타임리프 템플릿 등록
Hierarchical-style layouts
예제에서는 뷰 템플릿을 최대한 간단하게 설명하기 위해 header나 footer 같은 템플릿 파일을 반복해서 포함한다. 다음 링크의 Hierarchical-style layouts를 참고하면 중복을 제거할 수 있다.
b. 화면 계층과 서비스 계층 분리
폼 객체(MemberForm)를 사용해서 화면 계층과 서비스 계층을 명확하게 분리한다.
@Getter @Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수입니다.")
private String name;
private String city;
private String street;
private String zipcode;
}
Form 결과를 가져오는 BindingResult class를 사용하면 @Valid로 인한 에러가 생겼을 때 회원등록 폼으로 다시 돌아가도록 구현할 수 있다. Thymeleaf가 Spring과 밀접하게 통합돼 있기 때문에 가능한 기능이다. 에러 메시지를 화면에 보여주는 것도 가능하다.
@PostMapping(value = "/members/new")
public String create(@Valid MemberForm form, BindingResult result) {
if (result.hasErrors()) {
return "members/createMemberForm";
}
...
}
폼 객체 VS 엔티티 직접 사용
요구사항이 정말 단순할 때는 폼 객체(MemberForm) 없이 엔티티(Member)를 직접 등록과 수정 화면에서 사용해도 된다. 그러나 화면 요구사항이 복잡해지기 시작하면, 엔티티에 화면 처리 기능이 점점 증가한다. 결과적으로 엔티티는 점점 화면에 종속적으로 변하고, 유지 보수하기 어려워진다.
실무에서 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다. 화면이나 API에 맞는 폼 객체나 DTO를 사용해 처리하고, 엔티티는 최대한 순수하게 유지해야 한다.
!주의!
API를 만들 땐 절대 엔티티 자체를 반환하지 말아야 한다. 물론 예제처럼 서버 사이드에서 도는 SSR에서 사용하는 정도면 괜찮다.
c. 기타 문법
th:field="*{name}" 문법
model로 넘어온 객체의 필드를 참조한다. HTML로 확인해 보면 id="name" name="name"으로 변환되는 걸 확인할 수 있다.
<input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
'?' 사용 시 null을 무시한다.
아래에서 address가 null이면 뒤에 있는 것들(city, street, zipcode)은 진행하지 않는다.
<tbody>
<tr th:each="member : ${members}">
<td th:text="${member.id}"></td>
<td th:text="${member.name}"></td>
<td th:text="${member.address?.city}"></td>
<td th:text="${member.address?.street}"></td>
<td th:text="${member.address?.zipcode}"></td>
</tr>
</tbody>
💫변경 감지와 병합(merge)
JPA를 쓸 때 이 두 가지의 차이를 완벽하게 이해하고 사용해야 한다. 꼭 기억하자.
a. 상품 수정 실행
!주의!
지금 예제에선 상품 옆에 있는 수정 버튼을 누르면 해당하는 itemId를 받아와 그 상품을 수정할 수 있는 폼을 보여준다. 이때 itemId이 조작되어 전달될 수도 있다. 따라서 실무에서는 서버 단에서 수정을 요청한 회원이 해당 상품에 권한이 있는지 확인하는 로직을 추가해야 한다.
@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@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(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.saveItem(book);
return "redirect:/items";
}
b. 준영속 엔티티란?
영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.
여기서는 위의 itemService.saveItem(book)에서 수정을 시도하는 Book 개체를 말한다. Book 개체는 이미 DB에 한 번 저장돼서 식별자가 존재하는 상태다. 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 갖고 있다면 준영속 엔티티로 볼 수 있다.
영속 엔티티의 경우
비즈니스 로직에서 상품의 값을 바꾸면(수정), Repository에 있는 EntityManager가 트랜잭션 커밋 시점에 Dirty Checking을 진행하고, 변경사항을 DB에 반영하기 위해 UPDATE 문을 알아서 날린다.
준영속 엔티티의 경우
엔티티의 값을 아무리 바꿔도 DB에 UPDATE 문이 날아가지 않는다. 준영속 엔티티를 수정하는 방법은 아래 2가지가 있다.
변경 감지 기능 사용 / 병합(merge) 사용
c. 변경 감지 기능 사용
영속성 컨텍스트에서 엔티티를 다시 조회하고 데이터를 수정하는 기능이다.
트랜잭션 안에서 엔티티를 다시 조회하고, 변경할 값을 선택한다. 이후 트랜잭션 커밋 시점에 변경 감지 기능(Dirty Checking)이 동작해서 DB에 UPDATE SQL을 실행한다.
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}
아래처럼 ItemService에서 itemId를 받아 영속성 컨텍스트에서 꺼내는 방식도 있다. 영속 엔티티이므로 리포지토리에 save 하지 않아도 알아서 UPDATE SQL이 실행된다.
참고
set을 남발하지 말고 메서드를 하나 만들어서 작성하는 게 유지 보수하기에 좋다.
@Transactional
public void updateItem(Long itemId, Book param) {
Item findItem = itemRepository.findOne(itemId);
findItem.setName(param.getName());
findItem.setPrice(param.getPrice());
findItem.setStockQuantity(param.getStockQuantity());
}
d. 병합(merge) 사용
준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능으로, 지금 예제에서 사용하는 방식이다.
!주의!
변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 교체된다. 병합 시 값이 없으면 null로 UPDATE 될 위험도 존재한다.
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item mergeItem = em.merge(itemParam);
}
병합 동작 방식
1. merge() 실행
2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회
2-1. 만약 1차 캐시에 엔티티가 없으면 DB에서 엔티티를 조회하고, 1차 캐시에 저장
3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣음 (member 엔티티의 모든 값을 mergeMember에 밀어 넣는다. 이때 mergeMember의 "회원1"이라는 이름이 "회원명변경"으로 바뀐다.)
4. 영속 상태인 mergeMember를 반환한다.
병합 시 동작 방식을 간단히 정리
1. 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한다.
2. 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체(병합)한다.
3. 반환값이 영속 엔티티이므로 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 DB에 UPDATE SQL이 실행된다.
e. ItemRepository의 저장 메서드 분석
save() 메서드는 식별자 값이 없으면(null이면) 새로운 엔티티로 판단해서 영속화(persist)하고 식별자가 있으면 병합(merge)한다.
지금처럼 준영속 상태인 상품 엔티티를 수정할 때는 id 값이 존재하므로 병합을 진행한다.
save = 신규 데이터 저장 + 변경된 데이터 저장
@Repository
public class ItemRepository {
@PersistenceContext
EntityManager em;
public void save(Item item) {
if (item.getId() == null) {
em.persist(item);
} else {
em.merge(item);
}
}
//...
}
참고
save() 메서드는 식별자를 자동 생성해야 정상적으로 작동한다.
여기서 사용한 Item 엔티티의 식별자는 자동으로 생성되도록 @GeneratedValue로 선언했다. 따라서 식별자 없이 save() 메서드를 호출하면 persist()가 호출되면서 식별자 값이 자동으로 할당된다.
반면에 식별자를 직접 할당하도록 @Id만 선언했다고 가정해 보자. 이 경우 식별자를 직접 할당하지 않고, save() 메서드를 호출하면 식별자가 없는 상태로 persist()를 호출한다. 이러면 식별자가 없다는 예외가 발생한다.
참고
실무에서는 보통 업데이트 기능이 매우 제한적이다. 그런데 병합은 모든 필드를 변경해 버리고, 데이터가 없으면 null로 업데이트해버린다. 병합을 사용하면서 이 문제를 해결하려면, 변경 폼 화면에서 모든 데이터를 항상 유지해야 한다. 실무에서는 보통 변경 가능한 데이터만 노출하기 때문에, 병합을 사용하는 것이 오히려 번거롭다.
f. 가장 좋은 해결 방법 - 엔티티 변경 시 변경 감지 사용
엔티티를 변경할 땐 항상 변경 감지 기능(Dirty Checking)을 사용하자.
1. Controller에서 어설프게 엔티티를 생성(new) 하지 말자.
2. 트랜잭션이 있는 서비스 계층에 식별자(Id)와 변경할 데이터를 명확하게 전달하자. (파라미터 || DTO)
3. 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하자.
4. 트랜잭션 커밋 시점에 변경 감지가 실행된다.