enginner_s2eojeong

<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발> 2편 본문

Backend/SpringBoot

<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발> 2편

_danchu 2025. 2. 10. 21:48

<목차>

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를 사용하는 방식의 차이

  1. 직접 객체 생성 (new 키워드 사용):
    • 객체의 라이프사이클 관리: 개발자가 직접 객체를 생성하고, 객체의 생명주기를 관리해야 한다. 예를 들어, 싱글톤 패턴을 구현하고 싶다면 개발자가 직접 그 패턴을 관리해야 하는 어려움이 있다.
    • 재사용 어려움: 동일한 객체를 여러 곳에서 사용할 때마다 새로 객체를 생성해야 할 수도 있다.
    public class MyService {
        private MyRepository myRepository = new MyRepository();  // 직접 객체 생성
    }
    
  2. @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 생성 메서드가 호출
    • 새로운 주문이 들어올 때마다 필요한 초기화 작업을 수행
    • 동작 원리:
      1. 주문 요청 발생: 사용자가 주문을 하면 주문에 필요한 정보(예: 주문자 정보, 상품 정보, 배송 정보)가 입력된다.
      2. createOrder 호출: 주문이 생성되는 시점에서 createOrder(member, delivery, orderItems) 메서드를 호출하여, 입력된 정보로 새로운 주문을 생성한다.
        • Member, Delivery, OrderItem 정보가 매개변수로 전달되고, 이를 통해 Order 객체가 초기화된다.
      3. 연관 엔티티 설정:
        • 주문이 어떤 회원의 것인지 (Member), 주문에 포함된 상품 목록 (OrderItem), 배송 정보 (Delivery) 등 연관 관계를 설정하는 로직이 createOrder 내부에서 자동으로 처리된다.
      4. 주문 상태 및 주문 시간 설정:
        • 주문이 생성되면 자동으로 주문 상태는 ORDER로 설정되고, 주문 시간이 현재 시간으로 설정된다.
      5. 주문 저장: 생성된 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은 주문에서 각각의 상품을 처리한다.

 

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 - 웹 애플리케이션 개발>