Engineering Note

[SW Engineering] JPA @Transactional 어노테이션의 동작방법과 JPA 영속성 컨테스트 본문

SW Engineering

[SW Engineering] JPA @Transactional 어노테이션의 동작방법과 JPA 영속성 컨테스트

Software Engineer Kim 2025. 9. 24. 13:42

JPA @Transactional 어노테이션의 동작방법과 JPA 영속성 컨텍스트



이커머스 프로젝트 장바구니 기능을 위한 테스트 코드다. 유저가 상품을 구매하기 위해 장바구니에 상품을 담고, 처음 장바구니에 담은 상품 수량을 수정하는 기능을 테스트했다. 테스트를 하면서 JPA 영속성 컨텍스트와 @Transactional 어노테이션이 어떻게 동작하는지 확실히 이해하기 위해 다시 정리하기로 했다.

package com.shop.service;

import com.shop.constant.ItemSellStatus;
import com.shop.dto.CartItemDto;
import com.shop.entity.CartItem;
import com.shop.entity.Item;
import com.shop.entity.Member;
import com.shop.repository.CartItemRepository;
import com.shop.repository.ItemRepository;
import com.shop.repository.MemberRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class CartServiceTest {
    @Autowired
    private ItemRepository itemRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private CartService cartService;

    @Autowired
    private CartItemRepository cartItemRepository;

    @PersistenceContext
    private EntityManager em;

    public Item saveItem(){
        Item item = new Item();
        item.setItemNm("테스트 상품");
        item.setPrice(1000);
        item.setItemDetail("테스트 상품 상세 설명");
        item.setItemSellStatus(ItemSellStatus.SELL);
        item.setStockNumber(100);
        return itemRepository.save(item);
    }

    public Member saveMember(){
        Member member = new Member();
        member.setEmail("test@test.com");
        return memberRepository.save(member);
    }

    @Test
    @DisplayName("장바구니 담기 테스트")
    public void addCartTest(){
        Item item = saveItem();
        Member member = saveMember();

        CartItemDto cartItemDto = new CartItemDto();
        cartItemDto.setCount(5);
        cartItemDto.setItemId(item.getId());

        Long cartItemId = cartService.addCart(cartItemDto, member.getEmail());

        CartItem cartItem = cartItemRepository.findById(cartItemId).orElseThrow(RuntimeException::new);

        assertEquals(item.getId(), cartItem.getItem().getId());
        assertEquals(cartItemDto.getCount(), cartItem.getCount());

    }

    @Test
    @DisplayName("장바구니 수량 업데이트 테스트")
    public void updateCartItemCountTest(){
        Item item = saveItem();
        Member member = saveMember();

        CartItemDto cartItemDto = new CartItemDto();
        cartItemDto.setCount(4);
        cartItemDto.setItemId(item.getId());

        Long cartItemId = cartService.addCart(cartItemDto, member.getEmail());

        CartItem cartItem = cartItemRepository.findById(cartItemId).orElseThrow(RuntimeException::new);

        int count = cartItem.getCount() + 1;
        cartService.updateCartItemCount(cartItemId, count);

        assertEquals(count, cartItem.getCount());

    }
}

 

 

 

CartItem cartItem = cartItemRepository.findById(cartItemId).orElseThrow(RuntimeException::new); Spring Data JPA의 Repository 인터페이스를 통해 CartItem 을 조회하면 CartItem Entity는 영속성 컨텍스트에 담기면서 영속화된다. JPA가 관리하는 Entity가 된다는 뜻이다. 영속화된 Entity는 영속성 컨텍스트의 1차 캐시(메모리)에 존재하며, JPA가 이를 관리합니다.(Database의 테이블은 디스크에 존재한다.)

 

그리고 cartItem의 상품 수량필드 count 필드 값을 조회해서 수량을 1증가시킨 값을, cartService.updateCartItemCount 메서드에 전달했다.



CartService 클래스

package com.shop.service;

import com.shop.dto.CartDetailDto;
import com.shop.dto.CartItemDto;
import com.shop.entity.Cart;
import com.shop.entity.CartItem;
import com.shop.entity.Item;
import com.shop.entity.Member;
import com.shop.repository.CartItemRepository;
import com.shop.repository.CartRepository;
import com.shop.repository.ItemRepository;
import com.shop.repository.MemberRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thymeleaf.util.StringUtils;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class CartService {

    private final ItemRepository itemRepository;
    private final MemberRepository memberRepository;
    private final CartRepository cartRepository;
    private final CartItemRepository cartItemRepository;

    public Long addCart(CartItemDto cartItemDto, String email) {
        Item item = itemRepository.findById(cartItemDto.getItemId()).orElseThrow(EntityNotFoundException::new);
        Member member = memberRepository.findByEmail(email);

        Cart cart = cartRepository.findByMemberId(member.getId());
        if (cart == null) {
            cart = Cart.createCart(member);
            cartRepository.save(cart);
        }

        CartItem savedCartItem = cartItemRepository.findByCartIdAndItemId(cart.getId(), item.getId());

        if(savedCartItem != null) {
            savedCartItem.addCount(cartItemDto.getCount());
            return savedCartItem.getId();
        } else {
            CartItem cartItem = CartItem.createCartItem(cart, item, cartItemDto.getCount());
            cartItemRepository.save(cartItem);
            return cartItem.getId();
        }
    }

    @Transactional(readOnly = true)
    public List<CartDetailDto> getCartList(String email) {
        List<CartDetailDto> cartDetailDtoList = new ArrayList<>();

        Member member = memberRepository.findByEmail(email);
        Cart cart = cartRepository.findByMemberId(member.getId());
        if(cart == null){
            return cartDetailDtoList;
        }

        cartDetailDtoList = cartItemRepository.findCartDetailDtoList(cart.getId());

        return cartDetailDtoList;
    }

    @Transactional(readOnly = true)
    public boolean validateCartItem(Long cartItemId, String email){
        Member curMember = memberRepository.findByEmail(email);
        CartItem cartItem = cartItemRepository.findById(cartItemId).orElseThrow(EntityNotFoundException::new);
        Member savedMember = cartItem.getCart().getMember();

        if(!StringUtils.equals(savedMember.getEmail(), curMember.getEmail())){
            return false;
        }

        return true;
    }

    public void updateCartItemCount(Long cartItemId, int count){
        CartItem cartItem = cartItemRepository.findById(cartItemId).orElseThrow(EntityNotFoundException::new);

        cartItem.updateCount(count);
    }
}

 

 

updateCartItemCount는 cartItemId와 count를 인자로 전달받아 값을 수정한다. 이때 cartItem 엔티티의 updateCount() 메서드를 사용한다.

package com.shop.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "cart_item")
@Getter
@Setter
public class CartItem extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "cart_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cart_id")
    private Cart cart;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    private int count;

    public static CartItem createCartItem(Cart cart, Item item, int count) {
        CartItem cartItem = new CartItem();
        cartItem.setCart(cart);
        cartItem.setItem(item);
        cartItem.setCount(count);
        return cartItem;
    }

    public void addCount(int count) {
        this.count += count;
    }


    public void updateCount(int count){
        this.count = count;
    }
}


CartItem의 updateCount메서드는 CartItem 객체 인스턴스의 필드 값을 바꿔주기만 하지만 영속화된 엔티티의 값을 바꿔주면 메모리의 상품 수량의 값을 바뀐다. 이 시점에 Database의 값은 아직 바뀌지 않은 상태다. 쓰기지연 저장소에 update SQL이 저장되어 있고,  나중에 Transaction이 종료될 때, flush()가 호출되면서 save()의 호출 없이도 update 쿼리가 Database에 전송된다. 영속상태의 데이터가 변경되면 JPA가 이를 감지해서 데이터를 업데이트하는데 이를 더티체킹이라고 한다.

update 쿼리는 트랜잭션이 종료되는 시점에 Database에 반영되는데, 테스트코드가 종료되는 시점이다. 이는 Transactional 어노테이션이 클래스에 붙어 있기때문에 메서드레벨에도 적용되어 'updateCartItemCountTest'메서드의 로직이 하나의 트랜잭션으로 처리된다. 종료시점에 flush()가 호출되어 쓰기 지연 SQL이 실행되지만, 테스트 환경에서 @Transactional은 기본적으로 롤백 모드로 동작하므로 실제 DB에는 변경사항이 반영되지 않는다.

그래서 테스트 코드에서 assertEquals()로 검증하는 데이터는 메모리상의 데이터다.

 


영속성 컨텍스트 구성 요소

1. 1차 캐시 (First Level Cache)

  • 엔티티 저장소 역할
  • JPA 명세에 명시된 핵심 기능
  • 엔티티 인스턴스들을 ID 기반으로 저장
  • 동일성 보장과 성능 최적화 역할

2. 쓰기 지연 SQL 저장소 (Write-behind SQL Store)

  • JPA의 쓰기 지연(transactional write-behind) 기능을 위한 저장소
  • INSERT, UPDATE, DELETE SQL을 모아두었다가 flush 시점에 일괄 실행
  • Hibernate에서는 ActionQueue로 구현

 

 

더티체킹

  • 영속 상태의 엔티티 필드가 변경되면 JPA가 자동으로 감지하여 UPDATE SQL을 생성하는 기능
  • 별도의 save() 호출 없이도 트랜잭션 커밋 시점에 변경사항이 DB에 반영됨

 

 

updateCount() 실행 시점의 내부 동작

1. 1차 캐시 엔티티 상태 변경(메모리 변경, 즉시 발생)

 
java
cartItem.updateCount(count);  // this.count = count;
  • CartItem 엔티티 인스턴스의 count 필드가 메모리에서 즉시 변경됨

2. 더티 체킹 감지

  • JPA가 엔티티의 변경사항을 자동으로 감지
  • 엔티티가 "더티" 상태로 마킹됨

3. 쓰기 지연 SQL 저장소에 추가

  • UPDATE 쿼리가 쓰기 지연 SQL 저장소에 저장됨
  • 아직 데이터베이스로는 전송되지 않음
Comments