[Spring] JPA 트러블 슈팅(영속성 컨텍스트 , Dirty check 관련 이슈)

WOOK JONG KIM·2022년 11월 24일
0

패캠_java&Spring

목록 보기
68/103
post-thumbnail
@Test
void embedTest(){
		...
		User user1 = new User();
		user1.setName("joshua");
		user1.setHomeAddress(null);
		user1.setCompanyAddress(null);
		
		userRepository.save(user1);
		
		User user2 = new User();
		user2.setName("jordan");
		user2.setHomeAddress(new Address());
		user2.setCompanyAddress(new Address());
		
		userRepository.save(user2);

		entityManager.clear(); //영속성 컨텍스트 초기화 (준영속)
		
		userRepository.findAll().forEach(System.out::println);
		userRepository.findAllRowRecords().forEach(a -> System.out.println(a.values()));
}

영속성 컨텍스트 캐쉬에는 Address가 null인 user1과 빈 객체를 가진 user2 모두 존재

entityManager.clear()를 통해서 캐쉬를 지우고 새로 엔티티를 로딩해서 데이터 확인

clear()를 하지 않는 경우

@Test
void embedTest(){
		...
		//entityManager.clear();
		
		assertAll(
        () -> assertNull(userRepository.findById(7L).get().getHomeAddress()),
        () -> assertEquals(userRepository.findById(8L).get().getHomeAddress().getClass(), Address.class)
    );
}

clear를 통해 성능은 향상 되지만 캐시를 지움으로서 테스트가 실패됨
-> 영속성 캐시에서 가지고 있는 자바 엔티티와 DB 레코드간 불일치가 발생

@Test
void embedTest(){
		...
		entityManager.clear();
		
		assertAll(
        () -> assertNull(userRepository.findById(7L).get().getHomeAddress()),
        () -> assertEquals(userRepository.findById(8L).get().getHomeAddress().getClass(), Address.class)
    );
}

//실행결과
Multiple Failures (1 failure)
	java.lang.NullPointerException: <no message>
...

시간 불일치 현상

영속성 컨텍스트 캐쉬는 ms까지 표시 데이터베이스는 초까지 표시 (@Column(columnDefinition = "datetime" )

EntityManager.clear()를 하면 데이터베이스에 값을 조회함으로 초까지만 표시

Comment.java (commentedAt 추가)

...
public class Comment extends BaseEntity{
		...
		@Column(columnDefinition = "datetime")
    private LocalDateTime commentedAt;
}

CommentRepository.java 생성

@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
}

CommentRepositoryTest.java

@Test
@Transactional
void commentTest() {
    Comment comment = commentRepository.findById(3L).get();
    comment.setCommentedAt(LocalDateTime.now());

    commentRepository.saveAndFlush(comment);

    entityManager.clear(); //clear 여부로 결과가 다름 (주석여부로 테스트)

    System.out.println(commentRepository.findById(3L).get());
}

//실행 결과
//entityManager.clear()가 있는 경우 (데이터베이스 조회)
Comment(super=BaseEntity(createdAt=2021-08-18T21:45:31.205062, updatedAt=2021-08-18T21:45:31.907460), Id=3, comment=그냥 그랬습니다., commentedAt=2021-08-18T21:45:32)

//entityManager.clear()가 없는 경우 (영속성 컨텍스트 캐쉬 조회)
Comment(super=BaseEntity(createdAt=2021-08-18T21:49:13.881998, updatedAt=2021-08-18T21:49:14.463898700), Id=3, comment=그냥 그랬습니다., commentedAt=2021-08-18T21:49:14.402901700)

앞선 컬럼 Definition을 "dateTime(6) default now(6) 설정 후

// 이 경우 DB 테이블에 디폴트 값을 이렇게 지정하면 commentedAt 값을 세팅하지 않으면
    // 디비 에서 자동적으로 timestamp 값 넣도록
    @Column(columnDefinition = "datetime(6) default now(6)")
    private LocalDateTime commentedAt; // 댓글 남긴 시간 추가했다고 가정
	@Test
    @Transactional
    void commentTest(){
        Comment comment = new Comment();
        comment.setComment("별로에요");

        commentRepository.saveAndFlush(comment); // 그냥 save 시에는 다름, 테스트 위해 flush로 먼저 반영하엿음
        // entityManager.clear(); //clear 여부로 결과가 다름 (주석여부로 테스트), 영속성 캐쉬값 날리는 코드

        System.out.println(comment);
        
        commentRepository.findAll().forEach(System.out::println);
    }
    
    
    // 결과
    Comment(super=BaseEntity(createdAt=2022-11-24T17:06:12.892273, updatedAt=2022-11-24T17:06:12.892273), id=4, comment=별로에요, commentedAt=null)
    
    // DB 반영 내용
    Comment(super=BaseEntity(createdAt=2022-11-24T17:06:12.076920, updatedAt=2022-11-24T17:06:12.076920), id=1, comment=저도 좋았어요, commentedAt=2022-11-24T17:06:12.076920)
	Comment(super=BaseEntity(createdAt=2022-11-24T17:06:12.077634, updatedAt=2022-11-24T17:06:12.077634), id=2, comment=저는 별로 였습니다., commentedAt=2022-11-24T17:06:12.077634)
	Comment(super=BaseEntity(createdAt=2022-11-24T17:06:12.077940, updatedAt=2022-11-24T17:06:12.077940), id=3, comment=저는 그냥 그랬습니다., commentedAt=2022-11-24T17:06:12.077940)
	Comment(super=BaseEntity(createdAt=2022-11-24T17:06:12.892273, updatedAt=2022-11-24T17:06:12.892273), id=4, comment=별로에요, commentedAt=null)
    
    

commented_at 에 null 값이 들어감
-> 엔티티에는 Setter를 쓰지않았기에

Hibernate: 
    insert 
    into
        comment
        (created_at, updated_at, comment, commented_at, review_id) 
    values
        (?, ?, ?, ?, ?)

data.sql의 insert comment commented_at 칼럼을 넣지 않았음
-> 디비의 디폴트 값을 처리하게 됨 (원래라면)
-> 반면 insert시 null을 넣은것을 볼 수 있다
-> insert into시 쿼리에 commented_at 칼럼을 넣지 않으면 DB 디폴트 값이 반영되지만 칼럼을 포함하여 null 입력 시 디폴트 값을 받지 않는 것을 볼 수 있다

jpa는 모든 칼럼을 insert 칼럼으로 사용하기로 처리하기 때문


반면 Comment 클래스에 DynamicInsert 지정 시

@DynamicInsert
public class Comment extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String comment;

    @ManyToOne // 하나의 리뷰에 여러 댓글
    @ToString.Exclude
    private Review review;

    // 이 경우 DB 테이블에 디폴트 값을 이렇게 지정하면 commentedAt 값을 세팅하지 않으면
    // 디비 에서 자동적으로 timestamp 값 넣도록
    @Column(columnDefinition = "datetime(6) default now(6)")
    private LocalDateTime commentedAt; // 댓글 남긴 시간 추가했다고 가정
}

insert 시점에 동적으로 반영

insert 문에 데이터가 존재하는것만 포함시켜 진행
-> set CommentedAt 메서드가 실행되지 않았기에 insert문에서 제외

이후 위에 테스트 코드에서 clear()문을 실행 시

Comment(super=BaseEntity(createdAt=2022-11-24T17:18:35.495753, updatedAt=2022-11-24T17:18:35.495753), id=4, comment=별로에요, commentedAt=null)


Comment(super=BaseEntity(createdAt=2022-11-24T17:18:34.800511, updatedAt=2022-11-24T17:18:34.800511), id=1, comment=저도 좋았어요, commentedAt=2022-11-24T17:18:34.800511)
Comment(super=BaseEntity(createdAt=2022-11-24T17:18:34.801287, updatedAt=2022-11-24T17:18:34.801287), id=2, comment=저는 별로 였습니다., commentedAt=2022-11-24T17:18:34.801287)
Comment(super=BaseEntity(createdAt=2022-11-24T17:18:34.801687, updatedAt=2022-11-24T17:18:34.801687), id=3, comment=저는 그냥 그랬습니다., commentedAt=2022-11-24T17:18:34.801687)
Comment(super=BaseEntity(createdAt=2022-11-24T17:18:35.495753, updatedAt=2022-11-24T17:18:35.495753), id=4, comment=별로에요, commentedAt=2022-11-24T17:18:35.513536)

commentedAt 에 null이 들어가있지만 DB에는 값이 반영됨
-> 엔티티 캐시로 인해 DB의 실제 값과, 엔티티 간에 불일치가 발생하는 케이스

실제로 해당 캐쉬와 실제 값이 틀어지는 경우 많은 문제
-> ex) 이미 탈퇴한 회원이지만, 캐쉬에 값이 남아있어 로그인이 가능해짐


Dirty check 성능 이슈

영속성 컨텍스트는 @Transactional안에 있는 로직을 관리

영속화 되어 관리되는 엔티티에서 수정이 발생하면 save()가 없어도 수정문 실행

@Transactional(session 설정)을 제거하면 그 안에 영속성 컨텍스트를 관리하지 않으므로 수정문 실행X
-> comment 엔티티는 findAll 직후 영속성 관리가 끝남

//CommentService.java
@Transactional
public void updateSomething() {
    List<Comment> comments = commentRepository.findAll();
    for (Comment comment : comments){
        comment.setComment("별로에요");
//            commentRepository.save(comment);
    }
}

//CommentServiceTest.java
@Test
void commentTest() {
    commentService.init();
    commentService.updateSomething();
}


//실행결과
//조회 후 수정쿼리 실행
...

Hibernate: 
    update
        comment 
    set
        updated_at=?,
        comment=? 
    where
        id=?

만약 CommentService.java에

	@Transactional
    public void insertSomething(){
        Comment comment = new Comment();
        comment.setComment("이건 뭐죠?");
        
        // commentRepository.save(book);
    }

이러한 메서드를 만든 후 테스트에서 실행된다 해도 더티 체크 진행 안됨
-> DB 영속화하는 과정이 없기 때문
-> 하고자한다면 영속화(ex : save) : 주석 풀기
-> 또는 코드 첫줄에 Comment comment = commentRepository.findById(1L).get();
-> 이러면 findById에 의해 영속성 컨텍스트에 의해 관리되는 상태가 됨

Dirty Check

영속성 컨텍스트의 변경 감지를 통해 예상치 못한 수정이 발생 (save() 없는데 수정문 실행)

성능적인 이슈 발생
트랜잭션 내에서 조회를 한 엔티티에 대해선 Dirty Check하는 과정 발생 (대용량 데이터 경우 위험)

성능적인 이슈 해결 방법

@Transactional(readOnly = true)를 사용, 영속성 컨텍스트(seesion)의 Flush Mode가 MANUAL로 설정 (default Auto)

Flush가 Auto로 발생하지 않으므로 Dirty Check가 생략

//CommentService.java
@Transactional(readOnly = true)
public void updateSomething() {
    List<Comment> comments = commentRepository.findAll();
    for (Comment comment : comments){
        comment.setComment("별로에요");
//            commentRepository.save(comment);
    }
}

//CommentServiceTest.java
@Test
void commentTest() {
    commentService.init();
    commentService.updateSomething();
}

//실행결과
//조회 쿼리만 실행
select
    comment0_.id as id1_4_,
    comment0_.created_at as created_2_4_,
    comment0_.updated_at as updated_3_4_,
    comment0_.comment as comment4_4_,
    comment0_.commented_at as commente5_4_,
    comment0_.review_id as review_i6_4_ 
from
    comment comment0_
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID>{
	...
}

JPA 구현체에 readOnly true가 설정되어있기에 findAll()과 같은 메서드에 기본적으로 적용되어 있음

	@Override
	public List<T> findAll() {
		return getQuery(null, Sort.unsorted()).getResultList();
	}

save의 경우에는 별도의 트랜잭션을 적용하고있음
-> dirty check 적용

	@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}
profile
Journey for Backend Developer

0개의 댓글