enginner_s2eojeong
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발> 2편 본문
<목차>
4. 회원 도메인 개발
5. 상품 도메인 개발
6. 주문 도메인 개발
7. 웹 계층 개발
작년 하반기에 스프링부트 스터디를 진행하며 노션에 정리해놓았던 내용들입니다.
최근 연합 프로젝트를 진행하면서 당시 공부한 내용을 실제로 적용해볼 수 있었고 덕분에 JPA와 스프링 부트에 대한 개념을 더욱 확실히 다질 수 있었습니다. 이번에는 그 경험을 복습할 겸 좀 더 다듬고 정리된 형태로 블로그에도 공유해보려 합니다.
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발> 1편
1. 들어가기 전 - 용어 정리2. 도메인 분석 설계3. 애플리케이션 구현 준비 작년 하반기에 스프링부트 스터디를 진행하며 노션에 정리해놓았던 내용들입니다. 최근 연합 프로젝트를 진행하면서
s2eojeong.tistory.com
⬆️ 1편 보러가기
4. 회원 도메인 개발
MemberRepository 클래스
@Repository // 스프링 빈으로 스프링이 등록해줌. Component의 스캔 대상이 됨.
@RequiredArgsConstructor
public class MemberRepository {
//@PersistenceContext // 스프링이 생성한 엔티티 매니저를 주입해줌.
private final EntityManager em;
public void save(Member member){
em.persist(member);
}
public Member findOne(Long id){
return em.find(Member.class, id);
}
public List<Member> findAll(){ // JPQL --> FROM의 대상은 Entity (테이블 x)
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
- 스프링이 스프링 빈으로 등록해준다. JPA 예외를 스프링 기반 예외로 예외 변환
- @RequiredArgsConstructor
- final 필드를 이용해 클래스의 상태를 불변으로 만들 수 있게 (= 생성자를 자동으로 만들어줌) 해준다.
- 개발자가 명시적으로 생성자를 작성하지 않아도 Lombok이 자동으로 해당 필드에 대한 생성자를 생성해준다.
- @PersistenceContext
- EntityManager를 Spring 컨테이너에 의해 관리되도록 주입해준다.
- 개발자가EntityManager 인스턴스를 생성하지 않고도 데이터베이스와의 상호작용을 할 수 있게 한다.
객체를 생성하는 방식과 @Autowired를 사용하는 방식의 차이
- 직접 객체 생성 (new 키워드 사용):
- 객체의 라이프사이클 관리: 개발자가 직접 객체를 생성하고, 객체의 생명주기를 관리해야 한다. 예를 들어, 싱글톤 패턴을 구현하고 싶다면 개발자가 직접 그 패턴을 관리해야 하는 어려움이 있다.
- 재사용 어려움: 동일한 객체를 여러 곳에서 사용할 때마다 새로 객체를 생성해야 할 수도 있다.
public class MyService { private MyRepository myRepository = new MyRepository(); // 직접 객체 생성 }
- @Autowired 사용:
- 의존성 관리: Spring 컨테이너가 의존성(DI)을 주입하고 관리해주므로, 개발자는 객체 생성이나 의존성 관리를 신경 쓸 필요가 없다.
- 스프링 컨테이너가 객체를 싱글톤으로 관리하므로 메모리 사용을 최적화할 수 있다.
@Service public class MyService { @Autowired private MyRepository myRepository; // 스프링이 빈을 주입해줌 }
@Autowired와 @RequiredArgsConstructor의 관계
ex) @RequiredArgsConstructor를 사용한 경우
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
// MyRepository에 대한 생성자가 Lombok에 의해 자동 생성됨
}
- 이 코드는 생성자 주입 방식으로 memberRepository가 주입되며, Spring은 자동으로 해당 생성자를 호출해 의존성을 주입한다.
ex) 직접 생성자를 작성한 경우
public class MemberService {
private final MemberRepository memberRepository;
// 직접 생성자 작성
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
}
- 이 경우는 @RequiredArgsConstructor를 사용한 경우의 결과는 동일하지만 @RequiredArgsConstructor를 사용하면 코드를 더 간결하게 유지할 수 있다.
JPQL의 구조
public List<Member> findByName(String name){ // 이름으로 조회
return em.createQuery("select m from Member m where m.name = :name", Member.class)
.setParameter("name", name)
.getResultList();
}
- "select m from Member m where m.name = :name"에서 :name은 “named parameter”로, 실제로 쿼리를 실행할 때 :name에 해당하는 값이 무엇인지를 지정해 줘야한다.
- :name은 변수처럼 사용되는 자리표시자이기에 이 곳에 어떤 값을 넣을지 setParameter로 지정해야 한다.
- 이 작업이 없으면, JPQL은 :name이 어떤 값을 참조해야 하는지 알지 못하므로 실행이 불가능하기 때문에 :name에 어떤 값이 들어갈지 명시적으로 지정해주는 것이 setParameter의 역할
@Transactional(readOnly = true) - 읽기 전용
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository; // test 케이스에서도 에러 줄일 수 있음.
//회원가입
@Transactional // 쓰기에는 그냥 Transactional. 여기에는 기본으로 readOnly=false
public Long join(Member member){
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId();
}
}
- @Transactional : 트랜잭션, 영속성 컨텍스트
- readOnly=true` : 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨텍스트를 플러시 하지 않으므로 약간의 성능 향상(읽기 전용에는 다 적용).
- 메인 디폴트는 @Transactional(readOnly = true)으로 하되, 쓸 때(join)에는 @Transactional 명시하기.
5. 상품 도메인 개발
ItemRepository 클래스
public class ItemRepository {
private final EntityManager em;
public void save(Item item){
if(item.getId() == null){ // 아이템 처음 저장할 때
em.persist(item);
} else {
em.merge(item);
}
}
}
- 새로운 아이템 저장 (em.persist(item))
- item.getId() == null 조건은 아이템이 처음 저장되는 경우
- 영속성 컨텍스트에 처음으로 추가하는 상황 (CREATE)
- 기존 아이템 업데이트 (em.merge(item))
- item.getId() != null인 경우는 해당 아이템이 이미 데이터베이스에 존재한다는 뜻이다.
- 즉, 이 아이템은 기존에 저장된 상태이며, 아이템의 상태를 업데이트하려는 상황 (UPDATE)
- 이 경우 em.merge(item)을 호출하여, 해당 아이템의 변경 사항을 데이터베이스에 반영한다. 준영속 상태의 아이템이나 변경된 값을 가진 엔티티를 영속성 컨텍스트에 병합하고, 그 결과를 반영한다.
- merge를 호출하면 준영속 상태의 엔티티를 DB에서 가져와 그 상태에 변경된 내용을 반영하고, 병합된 상태의 엔티티를 반환한다.
만약 새로운 아이템인데 merge를 호출하면 데이터베이스에 해당 아이템이 없으므로 merge는 DB에서 찾을 수 없는 엔티티에 대한 병합을 시도하게 되어 불필요한 조회가 발생한다.
이미 데이터베이스에 저장된 아이템을 업데이트할 때 em.merge는 해당 아이템의 변경 사항을 반영한다. 이는 DB에 저장된 데이터를 덮어쓰는(UPDATE) 동작을 의미한다.
Item 추상 클래스
public abstract class Item {
//=비즈니스 로직=//
//재고 수량 증가하는 함수
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 = restStock;
}
}
왜 재고 수량을 관리하는 비즈니스 로직은 Item 클래스에 있나요 ?!
⇒ 객체 지향 설계의 원칙과 응집도 때문 !! ⬇️
1. 객체 지향 설계 원칙 (캡슐화)
- 객체 지향 설계에서 중요한 원칙 중 하나는 객체의 상태(여기서는 stockQuantity)와 그 상태를 변경하는 행동(여기서는 addStock, removeStock)이 하나의 객체 내에 존재해야 한다는 것이다.
- Item 객체는 자신의 상태인 stockQuantity를 관리하고 그 상태를 어떻게 변경할지를 결정하는 주체이기 때문에 재고 관리 로직이 Item 내부에 포함되는 것이 더 적절하다.
2. 응집도
- Item 클래스의 속성인 stockQuantity는 재고 관리(상태 변화)와 관련된 모든 로직(즉, addStock과 removeStock)과 같은 클래스에 속해야 한다.
- 만약 재고 관리 로직을 서비스 클래스에 넣으면, 상태를 변경하는 메서드와 그 상태가 분리되어 응집도가 떨어지게 된다. Item 객체가 자신의 상태와 관련된 모든 로직을 관리하는 것이 응집도를 높여주며 유지보수도 더 쉽다.
✅ BUT, 여러 엔티티를 동시에 변경해야 할 경우
- 한 엔티티의 상태 변경만 필요한 경우 → 엔티티 내부에서 상태 변경
- 여러 개의 엔티티를 변경해야 하는 경우 → 서비스에서 상태 변경 관리
→ 이런식으로 서비스 단에서 상태 변경 관리도 가능하다. 하지만 Entity 내부에 메서드로 정의하는 것이 권장된다.
3. 리포지토리(Repository)에서 상태 변경을 하면 안 되는 이유
- Repository는 데이터베이스와의 직접적인 CRUD 작업을 담당하는 계층이라 비즈니스 로직(객체 상태 변경 로직)이 들어가면 안 됨.
- 비즈니스 로직은 Service 또는 Entity에서 처리하는 것이 원칙.
최근 진행했던 프로젝트에서 특정 레시피의 스크랩/조회 수를 카운트하는 로직을 구현하였는데, DB에 수정된 데이터가 반영이 되어야한다는 생각에 Repoisitory에 스크랩/조회 증가/감소 로직을 구현했던 것이 생각났다. 프로젝트 피드백에 반영해야겠다..!ㅠㅠ
6. 주문 도메인 개발
Order 클래스
public class Order {
.
.
.
//==생성 메서드==//
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;
}
}
- //==생성 메서드==//
- 주문이 생성될 때마다 createOrder 생성 메서드가 호출
- 새로운 주문이 들어올 때마다 필요한 초기화 작업을 수행
- 동작 원리:
- 주문 요청 발생: 사용자가 주문을 하면 주문에 필요한 정보(예: 주문자 정보, 상품 정보, 배송 정보)가 입력된다.
- createOrder 호출: 주문이 생성되는 시점에서 createOrder(member, delivery, orderItems) 메서드를 호출하여, 입력된 정보로 새로운 주문을 생성한다.
- Member, Delivery, OrderItem 정보가 매개변수로 전달되고, 이를 통해 Order 객체가 초기화된다.
- 연관 엔티티 설정:
- 주문이 어떤 회원의 것인지 (Member), 주문에 포함된 상품 목록 (OrderItem), 배송 정보 (Delivery) 등 연관 관계를 설정하는 로직이 createOrder 내부에서 자동으로 처리된다.
- 주문 상태 및 주문 시간 설정:
- 주문이 생성되면 자동으로 주문 상태는 ORDER로 설정되고, 주문 시간이 현재 시간으로 설정된다.
- 주문 저장: 생성된 Order 객체는 데이터베이스에 저장/관리된다.
public class Order {
.
.
.
//==비즈니스 로직==//
//주문 취소
public void cancel(){
if(delivery.getStatus() == DeliveryStatus.COMP){
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();
}
}
//==비즈니스 로직==//
- 주문 취소 (cancel)
- 주문을 취소하는 로직을 정의한 메서드
- 주문이 취소되면 상태(orderStatus)를 CANCEL로 변경하고, 관련된 OrderItem의 재고를 원상복구
- 전체 주문 가격 조회 (getTotalPrice)
- 주문에 포함된 모든 OrderItem의 가격을 합산하여 총 주문 금액을 계산하는 메서드
OrderItem 클래스
public class OrderItem {
.
.
.
//==생성 메서드==//
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;
}
}
- //==생성 메서드==//
- 주문이 생성될 때마다 createOrderItem은 OrderItem 객체를 생성하고 초기화
- 하나의 주문(order)에 대해 여러개의 주문 상품(orderItem)이 만들어질 수 있다.
- 자세한 흐름:
1. 주문 요청 발생
- createOrderItem 메서드는 OrderItem 객체를 생성하고, 주문에 포함된 상품(Item), 상품 가격(orderPrice), 주문 수량(count) 등을 설정한다.
2. 상품(Item) 조회
- 주문할 상품은 데이터베이스에서 조회되며, 이 상품 정보는 Item 엔티티로 표현된다.
- Item은 재고(stock)와 가격(price) 등의 속성을 가지며, 사용자는 이 정보를 기반으로 주문할 수 있다.
3. 주문 항목(OrderItem) 생성 (createOrderItem 호출)
- 사용자가 선택한 각 상품에 대해 주문 항목(OrderItem)이 생성된다. 주문 항목은 주문에 포함된 개별 상품에 대한 정보를 저장하는 객체이다.
- 이때, OrderItem.createOrderItem() 메서드가 호출되어 개별 주문 항목이 생성된다.
public class OrderItem {
.
.
.
//비즈니스 로직
public void cancel() {
getItem().addStock(count); // 재고 수량을 원복해줌.
}
//조회 로직
//주문상품 전체 가격 조회
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
- //==비즈니스 로직==//
- 주문 취소(cancel):
- Order 클래스의 cancel() 메서드를 보면, 주문이 취소될 때 OrderItem이 각각의 상품에 대해 취소 처리를 하고있다.
- OrderItem 클래스의 cancel() 메서드가 호출되며, 그 안에서 해당 상품의 재고가 복구된다.
- Order는 주문 전체의 상태를 관리하고, OrderItem은 주문에서 각각의 상품을 처리한다.
- 주문 취소(cancel):
OrderService 클래스
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);
//주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
//주문 저장
orderRepository.save(order); // CASCADE 옵션 때문에 orderitem랑 delivery는 자동으로 합께 persist 되어서 DB에 반영
return order.getId();
}
//취소
@Transactional
public void cancelOrder(Long orderId){
//주문 엔티티 조회
Order order = orderRepository.findOne(orderId);
//주문 취소
order.cancel();
}
}
⇒ OrderService는 Order 엔티티와 OrderItem 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.
7. 웹 계층 개발
변경 감지와 병합(merge)
- 준영속 엔티티를 수정하는 2가지 방법
- 변경 감지 기능 사용
- @Transactional void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티 Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다. findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다. }
- 병합( merge ) 사용
- @Transactional void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티 Item mergeItem = em.merge(itemParam); }
주의: 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된 다. 병합시 값이 없으면 null 로 업데이트 할 위험도 있다. (병합은 모든 필드를 교체한다.)
참고: 실무에서는 보통 업데이트 기능이 매우 제한적이다. 그런데 병합은 모든 필드를 변경해버리고, 데이터가 없 으면 null 로 업데이트 해버린다. 병합을 사용하면서 이 문제를 해결하려면, 변경 폼 화면에서 모든 데이터를 항 상 유지해야 한다. 실무에서는 보통 변경가능한 데이터만 노출하기 때문에, 병합을 사용하는 것이 오히려 번거롭 다.
⇒ 가장 좋은 해결 방법은?
: 엔티티를 변경할 때는 항상 변경 감지를 사용하기 (Merge 사용 XXX)
public class ItemService {
private final ItemRepository itemRepository;
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity){
Item findItem = itemRepository.findOne(itemId); // 영속 상태의 엔티티를 찾아옴
findItem.setName(name);
findItem.setPrice(price);
findItem.setStockQuantity(stockQuantity);
}
}
@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@PathVariable Long 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(form.getAuthor());
// book.setIsbn(form.getIsbn());
itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
return "redirect:/items";
}
이로써 <실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발>에 대한 소개가 모두 끝이 났습니다 !
이 글을 읽으시는 모든 분들이 강의를 들으며 이해가 안되었던 부분들을 공부하는데 많은 도움이 되길 바라며,
다음 포스팅은 <스프링 MVC 1편 – 백엔드 웹 개발 핵심 기술>으로 돌아오도록 하겠습니다. ꉂꉂ(ᵔᗜᵔ*)
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발>
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발>
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발>
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발>
'Backend > SpringBoot' 카테고리의 다른 글
Jackson 매핑 오류: JSON 필드와 Java 필드 불일치 문제 해결하기 (feat. 모델 api 연결) (1) | 2025.02.21 |
---|---|
웹 서버(Web Server) vs 웹 애플리케이션 서버(WAS) (1) | 2025.02.10 |
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발> 1편 (0) | 2025.02.10 |