[JPA] 더티 체킹 문제 해결하기

728x90

서론

 

토이프로젝트를 진행하다가 insert 쿼리를 날리는 회원가입 로직을 작성하는데

자꾸 마지막에 쿼리에 update 쿼리가 발생하는 문제를 확인하였다.

 

DB에 insert 되는 데이터에는 크게 문제가 없긴했다.

하지만 궁금했다, 나는 분명 insert 로직만 작성을 했는데 왜 update 쿼리가 날라갈까?

 

본론을 들어가기 전에 간단한 더티 체킹 개념에 대해서 이야기 해보겠습니다.

 

더티 체킹이란?

JPA에서 더티 체킹(dirty checking)이란 영속성 컨테이너가 관리하는 엔티티의 상태를 감지해서, 변경된 부분이 있다면 자동으로 트랜잭션이 끝나는 시점에 데이터베이스에 반영하는 기능이다.

여기서 말하는 dirty는 “엔티티 데이터의 변경된 부분”을 뜻하며 checking은 변경된 부분을 감지한다는 의미이다.

 

따라서 개발자들가 직접 update 쿼리를 작성하지 않아도 자동으로 entity 를 update 한다.

즉 코드의 복잡성을 줄일 수 있다는 특징이 있다. 

 

더티 체킹 조건

  • 영속성 컨텍스트에서 관리되는 엔티티 -> @Entity 

영속성 컨텍스트는 엔티티를 처음 조회할 때 시작되며, 이후 변경을 감지한다. 

  • Transaction이 커밋되었을 때

트랜잭션이 커밋되기 전까지 영속성 컨텍스트는 변경사항을 추적하기만 하고, DB에 반영하지는 않는다.

따라서 트랜잭션이 커밋될 때 영속성 컨텍스트는 엔티티의 변경된 상태를 DB에 반영한다.

 

 

본론

아래 코드가 바로 문제의 코드이다.

@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class MemberService {
	private final MemberRepository memberRepository;
	private final LoginRepository loginRepository;
	private final VacationRepository vacationRepository;
	private final PasswordEncoder passwordEncoder;

	@Transactional
	public MemberResResponse registerMember(MemberReqRegister memberReqRegister) {
		Member member = MemberReqRegister.toEntity(memberReqRegister);
		member.registerDate();
		memberRepository.save(member);

		Login login = memberReqRegister.getLogin();
		login.registerDate();
		login.hashPassword(passwordEncoder);
		log.info("[Password : {}", login.getPassword());
		loginRepository.save(login);

		Vacation vacation = VacationDto.toEntity(memberReqRegister.getVacation());
		vacationRepository.save(vacation);

		return MemberResResponse.builder()
			.id(member.getId())
			.name(member.getName())
			.age(member.getAge())
			.img(member.getImg())
			.createdAt(LocalDateTime.now())
			.updatedAt(LocalDateTime.now())
			.vacation(memberReqRegister.getVacation())
			.build();
	}
}

 

왜 이런 문제가 발생할까? 코드를 보면서 많이 고민을 했다.

 

위 코드를 보고 내 엔티티를 다시 봤다

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor
@Getter
@Builder
@Table(name="member")
@Entity
public class Member {

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

	@OneToOne(mappedBy = "member", cascade = CascadeType.ALL)
	@JoinColumn(name="login_id")
	private Login login;

	@OneToOne(mappedBy = "member", cascade = CascadeType.ALL)
	@JoinColumn(name="vacation_id")
	private Vacation vacation;

	@Column(nullable = false, length = 10)
	private String name;

	@Column(nullable = false, length = 3)
	private Integer age;

	@Comment("휴가 승인서 파일")
	// 파일 저장할 수 있게 만들기
	private String img;

	// 지금까지 올린 휴가 기록

	@CreatedDate
	private LocalDateTime createdAt;

	@LastModifiedDate
	private LocalDateTime updatedAt;

	public void registerDate() {
		this.createdAt = LocalDateTime.now();
		this.updatedAt = LocalDateTime.now();
	}
}

 

이게 내 Member Entity 이다. 위 엔티티 연관관계에는 Login 정보를 담은 Login 객체를 가지고 있고 1대1 연관관계를 가지고 있다.

 

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
@Entity(name="login")
public class Login {

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

	@JsonIgnore
	@OneToOne(fetch = FetchType.LAZY)
	@JoinColumn(name="member_id")
	private Member member;

	@Column(unique = true)
	private String login;

	private String password;

	@JsonIgnore
	@Comment("마지막 로그인 시간 기록")
	private LocalDateTime lastLoginAt;

	//비밀번호 암호화
	public Login hashPassword(PasswordEncoder passwordEncoder) {
		this.password = passwordEncoder.encode(this.password);
		return this;
	}

	//비밀번호 확인
	public boolean checkPassword(String originalPassword, PasswordEncoder passwordEncoder) {
		return passwordEncoder.matches(originalPassword, this.password);
	}


	public void associateMember(Member member) {
		this.member = member;
	}

	public void registerDate() {
		this.lastLoginAt = LocalDateTime.now();
	}
}

 

위 코드는 Member 와 1대1 관계를 맺는 Login 정보를 담은 엔티티 이다.

 

맨처음에는 더티체킹이라는 개념을 몰랐다. 그래서 JPA 자동 update 쿼리 날림 이라고 검색을 해보니 더티체킹이라는 개념이 나와서

공부를 해보니 약간 어디서 문제가 발생했는지 알 것 같았다.

 

update 쿼리가 자동으로 날라가는 Entity 는 일단 Login Entity 이다.

 

딱 생각난게 있다. 비밀번호를 암호화 하기 위해서 Login Entity 에서 password 를 수정하는 메소드를 만들어서 

서비스 클래스에서 사용을 했다. 

 

근데 코드 순서를 보니

 

1) memberRepository.save(member);

2) loginRepository.save(login);

3) vacationRepository.save();

 

위에서 말해다 싶이 Member 에는 Login 과 1:1 연관관계이다.

그러므로 member 를 save() 하면 자동으로 연관된 객체 또한 insert 가 되는 것 이였다. 

 

그래서 Member 가 save() 되면서 Login 도 save() 를 하는 것이였다.

 

실제 날라간 쿼리

Hibernate:
insert
into
    member
(age, created_at, img, name, updated_at)
values
    (?, ?, ?, ?, ?)

Hibernate:
insert
into
    login
(last_login_at, login, member_id, password)
values
    (?, ?, ?, ?)

Hibernate:
update
    login
set
    last_login_at=?,
    login=?,
    member_id=?,
    password=?
where
    id=?

 

해결방법

생각보다 간단히 해결했다 insert 하는 순서를 바꿨더니 해결이 되었다...

@Transactional
	public MemberResResponse registerMember(MemberReqRegister memberReqRegister) {
    
		// 더티 체킹 때문에 로그인 객체가 먼저 save
		Login login = memberReqRegister.getLogin();
		login.registerDate();
		login.hashPassword(passwordEncoder);
		log.info("[Password : {}", login.getPassword());
		loginRepository.save(login);

		Member member = MemberReqRegister.toEntity(memberReqRegister);
		member.registerDate();
		memberRepository.save(member);

		Vacation vacation = VacationDto.toEntity(memberReqRegister.getVacation());
		vacationRepository.save(vacation);

		return MemberResResponse.builder()
			.id(member.getId())
			.name(member.getName())
			.age(member.getAge())
			.img(member.getImg())
			.createdAt(LocalDateTime.now())
			.updatedAt(LocalDateTime.now())
			.vacation(memberReqRegister.getVacation())
			.build();
	}

 

다른 방법으로는 dto 를 따로 만들어서 진행했다면, 위 더티 체킹이 발생하지 않았을 것이라고 생각합니다.

 

결론

 

잘 알고 사용하자.. 오늘도 좋은 정보를 얻어갔다

728x90