15장 고급 주제와 성능 최적화

자바 ORM 표준 JPA 프로그래밍 15장을 요약한 내용 입니다.

예외 처리

JPA 표준 예외들은 javax.persistence.PersistenceException의 자식 클래스다. 그리고 이 예외 클래스는 RuntimeException의 자식이다. 따라서 JPA 예외는 모두 언체크 예외다. JPA 표준 예외는 크게 2가지로 나눌 수 있다.

  • 트랜잭션 롤백을 표시하는 예외

  • 트랜잭션 롤백을 표시하지 않는 예외

트랜잭션 롤백을 표시하는 예

javax.persistence.EntityExistException

EntityManager.persist(...) 호출 시 이미 같은 엔티티가 있으면 발생한다.

java.persistence.EntityNotFoundException

EntityManager.getReference(...)를 호출했는데 실제 사용 시 엔티티가 존재하지 않으면 발생. refersh(...), lock(...)에서도 발생한다.

javax.persistence.OptimisticLockException

낙관적 락 충돌 시 발생한다.

javax.persistence.PessimisticLockException

비관적 락 충돌 시 발생한다.

javax.persistence.RollbackException EntityTransaction.commit()

실패 시 발생, 롤백이 표시되어 있는 트랜잭션 커밋 시에도 발생한다.

javax.persistence.TransactionRequiredException

트랜잭션이 필요할 때 트랜잭션이 없으면 발생. 트랜잭션 없이 엔티티를 변경할 때 주로 발생한다.

트랜잭션 롤백을 표시하지 않는 예

예외

설명

javax.persistence.NoResultException

Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생한다

javax.persistence.NonUniqueResultException

Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생한다.

javax.persistence.LockTimeoutException

비관적 락에서 시간 초과 시 발생한다

javax.persistence.QueryTimeoutException

쿼리 실행 시간 초과 시 발생한다.

JPA 예외를 스프링 예외로 변

JPA 예외

스프링 변환 예외

javax.persistence.PersistenceException

org.springgramework.orm.jpa.JpaSystemException

javax.persistence.NoResultException

org.springfreamework.dao.ExptyResultDataAccessException

javax.persistence.NonUniqueResultException

org.springframework.dao.IncorrectrResultSizeDataAccessException

javax.persistence.LockTimeoutException

org.springframework.dao.CannotAcquireLockException

javax.persistence.QueryTimeoutException

org.springframework.dao.DataIntegrityViolationException

javax.persistence.EntityExistException

org.springframework.orm.jpa.JpaObjectRetrievalFailurewException

javax.persistence.EntityNotFoundException

org.springframework.orm.jpa.JpaObjectRetrivalFailtueException

javax.persistence.OptimisticLockException

org.springframework.dao.JpaOptimisticLockingFailureException

javax.persistence.PessimisticLockException

org.springframework.dao.PessimisticLockingFailureException

javax.persistence.TransactionRequiredException

org.springframework.dao.InvalidDataAccessApiUsageException

javax.persistence.RollbackException

org.springframework.transaction.TransactionSystemException

JPA 예외를 스프링 예외로 변경 추

JPA 예외

스프링 변환 예외

java.lang.IllegalStateException

org.springframework.dao.InvalidDataAccessApiUsageException

java.lang.IllegalArgumentException

org.springframework.dao.InvalidDataAccessApiUsageException

스프링 프레임워크에 JPA 예외 변환기 적용

JPA 예외를 스프링 프레임워크가 제공하는 추상화된 예외로 변경하려면 PersistenceExceptionTransactionPostProcessor를 스프링 빈으로 등록하면 된다. 이것은 @Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.

<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor" />
@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
	return new PersistenceExceptionTranslationPostProcessor();
}

만약 예외를 변환하지 않고 그대로 반환하고 싶으면 다음처럼 throws 절에 그대로 반환할 JPA 예외나 JPA 예외의 부모 클래스를 직접 명시 하면 된다.

@Repository
public class NoResultExceptionTestService {
	@PersistenceContext EntityManager em;

	public Member findMember() throws javax.persistence.NoResultException {
		return em.creteQuery("select m from Member m", Member.class).getSingleResult();
	}
}

트랜잭션 롤백 시 주의사항

트랜잭션롤백하는 것은 데이터베이스의 반영 사항만 롤백 하는 것이지 수정한 자바 객체까지 원 상태로 복구해주지는 않는다. 예를 들어 엔티티를 조회해서 수정하는 중에 문제가 있어서 트랜잭션을 롤백하면 데이터베이스의 데이터는 원래대로 복구되지만 객체는 수정된 상태로 영속성 컨텍스트에 남아 있다. 따라서 새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear()를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 한다.

기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.

문제는 OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다. 스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 트래잭션 롤백시 영속성 컨텍스트를 초기화해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.

엔티티 비교

영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 1차 캐시에 엔티티가 저장된다. 이 1차 캐시 덕분에 변경 감지 기능도 동작하고, 이름 그대로 1차 캐시로 사용 되어서 데이터베이스를 통하지 않고 데이터를 바로 조회할 수도 있다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional
public class MemberServiceTest {

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

		// When
		Long saveId = memberService.join(member);

		// Then
		Member findMember = memberRepository.findOne(saveId);
		assertTrue(member == findMember); // true 참조값 비교
	}
}

이것은 같은 트랜잭션 범위에 있으므로 같은 영속성 컨텍스트를 사용하기 때문이다. 따라서 영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다.

  • 동일성: == 비교가 같다

  • 동등성 : equals() 비교가 같다

  • 데이터베이스 동등성 : @Id인 데이터베이스 식별자가 같다

정리하자면 동일성 비교는 같은 영속성 컨텍스트의 관리를 받는 영속 상태의 엔티티에만 적용할 수 있다. 그렇지 않을 때는 비즈니스 키를 사용한 동등성 비교를 해야 한다.

프록시 심화 주제

프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있다. 이로 인해 예상하지 못한 문제들이 발생하기도 하는데, 어떤 문제가 발생하고 어떻게 해결해야 하는지 알아보자

영속성 컨텍스트와 프록시

엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals() 메소드를 오버라이딩하고 비교하면 된다. 그런데 IDE나 외부 라이브러리를 사용해서 구현한 equals() 메소드로 엔티티를 비교할 때, 비교 대상이 원본 엔티티면 문제가 없지만 프록시면 문제가 발생할 수 있다.

@Entity
public class Member {
	@Id
	private String id;
	private String name;

	...

	@Override
	public boolean equals(Object obj) {
		if (this == obj) return true;
		if (obj == null) return false;
		if (this.getClass() != obj.getClass()) return false; // 1

		Member member = (Member) obj;

		if (this.name != null ? !this.name.equals(member.name) : member.name != null) //
			return false;

		return true;
	}
}

테스트 코드를 작성해보자

@Test
public void 프록시와_동등성비교() {
        Member saveMember = new Member("member1", "회원1");
        em.persist(saveMember);
        em.flush();
        em.clear();
        
        Member newMember = new Member("member1", "회원1");
        Member refMember = em.getReference(Member.class, "member1");
        
        Assert.assertTrue(newMember.equals(refMember)); // false
}

왜 이런 문제가 발생할까?

  • 프록시는 원본을 상속받은 자식 타입이므로 프록시의 타입을 비교할 때는 == 비교가 아닌 instanceof를 사용해야 한다,. 따라서 다음처럼 변경해야 한다.

    if (!(obj instanceof Member)) return false;
  • memner.name을 보면 프록시의 멤버변수에 직접 접근하는데 이 부분을 주의깊게 봐야 한다. 프록시는 실제 데이터를 가지고 있지 않기 때문에 프록시의 멤버 변수에 직접 접근하면 아무값도 조회할 수 없다. 그러므로 프록시의 데이터를 조회할 때는 접근자(Getter)를 사용해야 한다.

상속관계와 프록시

상속 관계를 프록시로 조회할 때 발생할 수 있는 문제점과 해결방안을 알아보자

위와 같은 구조의 클래스 모델을 생성할 경우 프록시를 부모 타입으로 조회하면 문제가 발생한다.

@Test
public void 부모타입으로_프록시조회() {
	//테스트 데이터 준비
	Book saveBook = new Book();
	saveBook.setName("jpaBook");
	saveBook.setAuthor("kim");
	em.persist(saveBook);

	em.flush();
	em.clear();

	//테스트 시작
	Item proxyItem = em.getReference(Item.class, saveBook.getId());
	System.out.println("proxyItem = " + proxyItem.getClass());

	if (proxyItem instanceof Book) {
		System.out.println("proxyItem instanceof Book");
		Book book = (Book) proxyItem;
		System.out.println("책 저자 = " + book.getAuthor());
	}

	//결과 검증
	Assert.assertFalse(proxyItem.getClass() == Book.class);
	Assert.assertFalse(proxyItem instanceof Book);
	Assert.assertTrue(proxyItem instanceof Item);
}

그런데 출력 결과를 보면 기대와는 다르게 저자가 출력되지 않은 것을 알 수 있다.

왜 원하는 출력값이 다를까?

실제 조회된 엔티티는 Book이므로 Book 타입을 기반으로 원본 엔티티 인스턴스가 생성된다. 그런데 em.getReference() 메소드에서 Item 엔티티를 대상으로 조회 했으므로 프록시인 proxyItemItem 타입을 기반으로 만들어진다. 이런 이유로 다음 연산이 기대와 다르게 false를 반환한다. 왜냐하면 proxyItemItem&Proxy 타입이고 이 타입은 Book 타입과 관계가 없기 때문이다.

proxyItem instanceof Book // false

따라서 직접 다운캐스팅을 해도 문제가 발생한다.

Book book = (Book) proxyItem; // java.lang.ClassCastException

그렇다면 상속관계에서 발생하는 프록시 문제를 어떻게 해결해야 할까?

  • JPQL로 대상 직접 조회

    Book jpqlBook = em.createQuery
    	("select b from Book b where b.id=:bookId", Book.class)
    	.setParameter("bookId", item.getId())
    	.getSingleResult();
  • 프록시 벗기기

    	...
    	Item item = orderItem.getItem();
    	Item unProxyItem = unProxy(item);
    	
    	if (unProxyItem instanceof Book) {
    		System.out.println("proxyItem instanceod Book");
    		Book book = (Book) unproxyItem;
    		System.out.println("책 저자 = " + book.getAuthor());
    	}
    	
    	Assert.assertTrue(item != unProxyItem);
    }
    
    //하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
    public static <T> T unProxy(Object entity){
    	if(entity instanceof HibernateProxy) {
    		entity = ((HibernateProxy) entity)
    				.getHibernateLazyInitializer()
    				.getImplementation();
    	}
    	return (T) entity;
    }

    그런데 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.

    item == unProxyItem // false
  • 기능을 위한 별도의 인터페이스 제공

    public interface TitleView {
    	String getTitle();
    }
    
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "DTYPE")
    public abstract class Item implements TitleView {
    	@Id @GeneratedValue
    	@Column(name = "ITEM_ID")
    	private Long id;
    	
    	...
    }
  • 비지터 패턴

    public interface Visitor {
    	void visit(Book book);
    	void visit(Album album);
    	void visit(Movie movie);
    }
    public class PrintVisitor implements Visitor {
    	@Override
    	public void visit(Book book) {
    		//넘어오는 book은 Proxy가 아닌 원본 엔티티다. 
    		System.out.println("book.class = " + book.getClass());
    	}
    	
    	@Override
    	void visit(Album album) {...}
    	@Override
    	void visit(Movie movie) {...}
    @Entity
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "DTYPE")
    public abstract class Item {
    
    	@Id @GeneratedValue
    	@Column(name = "ITEM_ID")
    	private Long id;
    
    	...
    
    	public abstract void accept(Visitor visitor);
    }
    
    @Entity
    @DiscriminatorValue("B")
    public class Book extends Item {
    
    	...
    	@Override
    	public void accept(Visitor visitor){
    		visitor.visit(this);
    	}
    }
    @Test
    public void 상속관계와_프록시_VisitorPattern() {
    	...
    	OrderItem orderItem = em.find(OrderItem.class, orderItemId);
    	Item item = orderItem.getItem();
    
    	//PrintVisitor
    	item.accept(new PrintVisitor());
    }
    public class PrintVisitor implements Visitor {
    	public void visit(Book book) {
    		//넘어오는 book은 Proxy가 아닌 원본 엔티티다. 
    		System.out.println("book.class = " + book.getClass());
    
    	}
    	public void visit(Album album) {...}
    	public void visit(Movie movie) {...}
    }

    비지터 패턴의 장점은 다음과 같다.

    • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.

    • instanceof와 타입 캐스팅 없이 코드를 구현할 수 있다.

    • 알고리즘과 객체 주고를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.

    단점은 다음과 같다.

    • 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해하기 어렵다.

    • 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

성능 최적화

JPA로 애플리케이션을 개발할 때 발생하는 다양한 성능 문제와 해결 방안을 알아보자.

N+1 문제

@Entity
public class Member {

	@Id @GeneratedValue
	private Long id;

	@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
	private List<Order> orders = new ArrayList<Order>();

	...
}
@Entity
public class Order {
	@Id @GeneratedValue
	private Long id;

	@ManyToOne
	private Member member;
	...
}

즉시 로딩과 N+1

em.find() 메소드로 조회하면 즉시 로딩으로 설정한 주문정보도 함께 조회한다.

em.find(Member.class, id);

// 결과값
SELECT M.*, O.*
FROM
	MEMBER M
OUTER JOIN ORDERS O ON M.ID = O.MEMNBER_ID

문제는 JPQL을 사용할 때 발생한다.

List<Member> members = 
	em.createQuery("select m from Member m", Member.class)
	.getResultList();

// 결과 SQL 로그
SELECT * FROM MEMNER
SELECT * ORDERS WHERE MEMBER_ID =1 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =2 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =3 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =4 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =5 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =6 // 회원과 연관된 주문

지연 로딩과 N+1

for (Member member : members) {
	//지연 로딩 초기화
	System.out.println("member = " + member.getOrders().size();
}

// 결과 SQL 로그
SELECT * ORDERS WHERE MEMBER_ID =1 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =2 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =3 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =4 // 회원과 연관된 주문
SELECT * ORDERS WHERE MEMBER_ID =5 // 회원과 연관된 주문

N+1 문제 해결 방법

페치 조인 사용

페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 N+1 문제가 발생하지 않는다.

select m from Member m join fetch m.orders

// 결과 SQL 로그
SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID

참고로 이 예제는 일대다 조인을 했으므로 결과가 늘어나서 중복된 결과가 나타날 수 있다. 따라서 JPQLDISTINCT를 사용해서 중복을 제거하는 것이 좋다.

하이버네이트 @BatchSize

BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해서 조회한다. 만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행한다.

@Entity
public class Member {

	@Id @GeneratedValue
	private Long id;

	@BatchSize(size = 5)
	@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
	private List<Order> orders = new ArrayList<Order>();

	...
}

하이버네이트 @Fetch(FetchMode.SUBSELECT)

FetchModeSUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N+1 문제를 해결할 수 있다.

@Entity
public class Member {

	@Id @GeneratedValue
	private Long id;

	@Fetch(FetchMode.SUBSELECT)
	@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
	private List<Order> orders = new ArrayList<Order>();

	...
}
select m from Member m where m.id > 10

// 결과 SQL 로그
SELECT O FROM ORDERS O
	WHERE O.MEMBER_ID IN (
		SELECT 
			M.ID
		FROM
			MEMBER M
		WHERE M.ID > 10
	}

읽기 전용 쿼리의 성능 최적화

엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지 까지 얻을 수 있는 혜택이 많다. 하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다. 이때는 읽기 전용으로 엔티티를 조회하면 메모리 사용량최적화할 수 있다.

@Transactional(readOnly = true)

위와 같이 읽기 전용으로 설정하면 트랜잭션커밋해도 영속성 컨텍스트를 플러시하지 않는다. 그러므로 플러시할 때 일어나는 스냅샷 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상된다.

엔티티 매니저의 플러시 설정에는 AUTO, COMMIT 모드만 있고, MANUAL 모드가 없다. 반면에 하이버네이트 세션의 플러시 설정에는 MANUAL 모드가 있다. MANUAL 모드는 강제로 플러시를 호풀하지 않으면 절대 플러시가 발생하지 않는다.

배치 처리

수백만 건의 데이터를 배치 처리해야 하는 상황이라 가정해보자. 일반적인 방식으로 엔티티를 계속 조회하면 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족 오류가 발생한다. 따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야 한다. 배치 처리는 아주 많은 데이터를 조회해서 수정한다. 이때 수많은 데이터를 한번에 메모리에 올려둘 수 없어서 2가지 방법을 주로 사용한다.

  • 페이징 처리

  • 커서

JPA 페이징 배치 처리

EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();

tx.begin();

int pageSize = 100;
for(int i =0; i<10; i++){
	List<Product> resultList = em.createQuery("select p from Product p", Product.class)
					.setFirstResult(i * pageSize)
					.setMaxResults(pageSize)
					.getResultList();
	// 비즈니스 로직 실행
	for( Product product : resultList) {
		product.setPrice(product.getPrice() + 100);
	}

	em.flush();
	em.clear();
}

tx.commit();
em.close();

이는 한번에 100건씩 페이징 쿼리로 조회하면서 상품의 가격을 100원씩 증가한다. 그리고 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다.

하이버네이트 scroll 사용

하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원한다.

EntityTransaction tx = em.getTransaction();
Session session = em.unwrap(Session.class);

tx.begin();
ScrollableResults scroll = session.createQuery("select p from Product p")
					.setCacheMode.IGNORE) // 2차 캐시 기능을 끈다
					.scroll(ScrollMode.FORWARD_ONLY);

int count = 0;

while (scroll.next()) {
	Product p = (Product) scroll.get(0);
	p.setPrice(p.getPrice() + 100);

	count++;
	if(count % 100 ==0) {
		session.flush(); // 플러시
		session.clear(); // 영속성 컨텍스트 초기화
	}
}

tx.commit();
session.close();

scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메소드를 사용해서 하이버네이트 세션을 구한다. 다음으로 쿼리를 조회하면서 scroll() 메소드로 ScrollableResults 객체를 반환받는다. 이 객체의 next() 메소드를 호출하면 엔티티를 하나씩 조회할 수 있다.

SQL 쿼리 힌트 사용

SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다. SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용한다.

Session session = em.unwrap(Session.class); // 하이버네이트 직접 사용
List<Member> list = session.createQuery("select m from Member m")
				.addQueryHint("FULL (MEMBER)")
				.list();

// 실행 결과
select 
	/*+ FULL (MEMBER) */ m.id, m.name
from
	Member m

정리

  • JPA의 예외는 트랜잭션 롤백을 표시하는 예외와 표시하지 않는 예외로 나눈다. 트랜잭션을 롤백하는 예외는 심각한 예외이므로 트랜잭션을 강제로 커밋해도 커밋되지 않고 롤백된다.

  • 스프링 프레임워크는 JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.

  • 프록시를 사용하는 클라이언트는 조회한 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있어야 한다. 하지만 프록시는 기술적인 한계가 있으므로 한계점을 인식하고 사용해야 한다.

  • JPA를 사용할 때는 N+1 문제를 가장 조심해야 한다. N+1 문제는 주로 페치 조인을 사용해서 해결한다.

  • 엔티티를 읽기 전용으로 조회하면 스냅샷을 유지할 필요가 없고 영속성 컨텍스트도 초기화해야 한다.

  • JPA는 SQL 쿼리 힌트를 지원하지 않지만 하이버네이트 구현체를 사용하면 SQL 쿼리 힌트를 사용할 수 있다.

  • 트랜잭션을 지원하는 쓰기 지연 덕분에 SQL 배치 기능을 사용할 수 있다.

질문

예외에 따라 롤백기능이 다름

  • 체크드 → 롤백 안됨

  • 언 체크드 → 롤백됨

비관적 락 해결 방법?

Last updated