JPA(Java Persistence API)를 사용하여 엔티티 변경 이벤트를 자동으로 감지하고, 감지된 이벤트에 대한 정보를 저장하는 기능이다.
일반적으로 데이터베이스에서는 데이터 변경 이력을 추적하고자 할 때 트리거나 로그 테이블을 이용해 구현한다. 그러나 JPA Auditing을 사용하면 엔티티 객체의 변경 이벤트를 감지하여 이벤트에 대한 정보를 엔티티 객체 자체에 저장하거나, 별도의 변경 이력 테이블에 저장할 수 있다.
JPA Auditing은 @CreatedDate, @LastModifiedDate와 같은 어노테이션을 사용하여 엔티티 객체의 생성 시간과 마지막 수정 시간을 자동으로 기록할 수 있다. 또한, @CreatedBy와 @LastModifiedBy 어노테이션을 사용하여 엔티티를 생성하거나 수정한 사용자 정보를 자동으로 기록할 수 있다.
이러한 기능을 사용하면 애플리케이션에서 데이터 변경 이력을 추적하는 데 유용하며, 보안 감사 추적, 데이터 무결성 확인 등 다양한 목적으로 활용할 수 있다.
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@SpringBootApplication
public class TodolistApplication {
public static void main(String[] args) {
SpringApplication.run(TodolistApplication.class, args);
}
}
@Getter @Setter
@ToString
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(name = "cre_time", nullable = false)
protected LocalDateTime creTime;
@CreatedBy
@Column(name = "cre_member_id", nullable = false)
protected Long creMemberId;
@LastModifiedDate
@Column(name = "upd_time", nullable = false)
protected LocalDateTime updTime;
@LastModifiedBy
@Column(name = "upd_member_id", nullable = false)
protected Long updMemberId;
}
@MappedSuperClass
엔티티의 공통 매핑 정보가 필요할 때 주로 사용한다. 즉, 부모 클래스(엔티티)에 필드를 선언하고 단순히 속성만 받아서 사용하고싶을 때 사용하는 방법이다.
BaseEntity를 생성하고 Auditing 기능이 필요한 엔티티 클래스에서 사용할 것이기 때문에 @MappedSuperClass 어노테이션을 사용하는 것이다.
@CreatedDate
해당 엔티티가 생성될 때, 생성하는 시각을 자동으로 삽입해준다.
@CreatedBy
해당 엔티티가 생성될 때, 생성하는 사람이 누구인지 자동으로 삽입해준다. 생성하는 주체를 지정하기 위해서 AuditorAware<T>
를 지정해야 한다.
@LastModifiedDate
해당 엔티티가 수정될 때, 수정하는 시각을 자동으로 삽입해준다.
@LastModifiedBy
해당 엔티티가 수정될 때, 수정하는 주체가 누구인지 자동으로 삽입해준다. 생성하는 주체를 지정하기 위해서 AuditorAware<T>
를 지정해야 한다.
@Configuration
public class JpaAuditConfiguration {
@Bean
public AuditorAware<Long> auditorProvider() {
return new AuditorAwareImpl();
}
}
AuditorAware<T>
인터페이스 구현생성하는 주체(@CreatedBy, @LastModifiedBy)를 지정하기 위해서 AuditorAware<T>
를 구현한다.
@Slf4j
public class AuditorAwareImpl implements AuditorAware<Long> {
@Override
public Optional<Long> getCurrentAuditor() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getPrincipal().equals("anonymousUser")) {
return Optional.of(99999999L);
}
Long memberId = CommonUtil.getLoginMemberId();
return Optional.of(memberId);
}
}
원래 if(authentication.getPrincipal().equals("anonymousUser"))
이 조건이 아닌 if(authentication == null || !authentication.isAuthenticated())
이 조건을 사용했었다.
로그인을 하지 않았는데, authentication 객체에 익명사용자 값이 들어가 있어서 위와 같이 코드를 수정하였다.
Spring Security - Anonymous Authentication 문서
위와 같이 Auditing 설정을 완료하고 BaseEntity
객체를 상속받은 엔티티를 생성하거나 수정할 때 @CreatedDate
, @CreatedBy
, @LastModifiedDate
, @LastModifiedBy
어노테이션이 적용된 필드가 자동으로 저장, 수정된다.
@CreatedBy
, @LastModifiedBy
값 할당을 위해 AuditorAware
인터페이스 구현체인 AuditorAwareImpl를 아래와 같이 구현하였다.
@Slf4j
public class AuditorAwareImpl implements AuditorAware<Long> {
@Override
public Optional<Long> getCurrentAuditor() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getPrincipal().equals("anonymousUser")) {
return Optional.of(99999999L);
}
Long memberId = CommonUtil.getLoginMemberId();
return Optional.of(memberId);
}
}
99999999L
값 할당CommonUtil.getLoginMemberId()
값 할당CommonUtil.getLoginMemberId()
메서드는 아래와 같다.
public static Long getLoginMemberId() {
MemberDTO memberDTO = memberService.getMyMemberWithAuthorities();
return memberDTO.getMemberId();
}
@Getter @Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberDTO {
private Long memberId;
private String email;
private String password;
private String nickname;
private LocalDate birthDate;
private String genderCode;
private String phoneNo;
private Set<AuthorityDTO> authorityDtoSet = new HashSet<>();
public static MemberDTO from(Member member) {
if (member == null) return null;
return MemberDTO.builder()
.memberId(member.getMemberId())
.email(member.getEmail())
.nickname(member.getNickname())
.birthDate(member.getBirthDate())
.genderCode(member.getGenderCode())
.phoneNo(member.getPhoneNo())
.authorityDtoSet(member.getAuthorities().stream()
.map(authority -> AuthorityDTO.builder().authorityName(authority.getAuthorityName()).build())
.collect(Collectors.toSet()))
.build();
}
}
@Transactional(readOnly = true)
public MemberDTO getMyMemberWithAuthorities() {
Optional<String> email = SecurityUtil.getCurrentUsername();
Member member = memberRepository.findOneWithAuthoritiesByEmail(email.get())
.orElseThrow(() -> new NotFoundMemberException("Member not found"));
return MemberDTO.from(member);
}
@Repository
@Transactional(readOnly = true)
public interface MemberRepository extends JpaRepository<Member, Long>, MemberCustomRepository {
@EntityGraph(attributePaths = "authorities")
Optional<Member> findOneWithAuthoritiesByEmail(String email);
}
위 코드와 같이 @CreatedBy
, @LastModifiedBy
속성 수정은 findOneWithAuthoritiesByEmail(String email)
메소드 이름을 지정한 쿼리를 사용하여 MemberId 값을 가져오도록 구현하였다.
현 상황에서 아래의 api(changeCompleteYn())를 호출하였을 때 StackOverFlowError
가 발생하였다..
// Controller
@PatchMapping("/complete/{todoId}")
public void changeCompleteYn(@PathVariable Long todoId) {
todoService.changeCompleteYn(todoId);
}
// Serivce
@Transactional
public void changeCompleteYn(Long todoId) {
Todo todo = todoRepository.findById(todoId)
.orElseThrow(() -> new ApiException("TodoId Not Found", ErrorMessage.DATA_NOT_FOUND));
todo.changeCompleteYn();
}
// 엔티티 객체 내의 메소드
public void changeCompleteYn() {
this.completeYn = this.completeYn.equals("N") ? "Y" : "N";
}
위 로그를 보면 todo 엔티티를 todoId
를 사용하여 select하는 것 까지는 정상적으로 작동하며, 아래와 같이 completeYn
속성 또한 정상적으로 Dirty Checking(DB 반영x)이 되는 것을 볼 수 있다.
completeYn 속성 "N" -> "Y"
위와 같이 todo 엔티티를 수정하고 flush()하기 전 내가 구현해 놨던 AuditorAwareImpl
로직을 타서 updMemberId
와 updTime
필드의 값을 수정 후 DB에 반영해야 맞다.
하지만 이때 에러가 발생했다...
Line.21에서 현재 로그인 사용자의 memberId를 가져와야 하는데, 값을 가져오지 못하고 무한 루프가 발생한 것이다.
위 에러로그에서 본 바와 같이 todo Entity만 select 한 뒤 memberId를 select하는 쿼리 이벤트는 발생하지 않았다. 쿼리 자체가 작성되지 않는다..
스택 프레임을 살펴보니
위와 같이 flush 이벤트가 발생하는 듯 보였다.
flush()는 DB에 변경사항을? 반영한다는 것인데, 나는 memberId
를 가져오기 위해 select만 하였을 뿐인데 왜 DB에 반영하려 하는지 몰랐다.
DB에 반영할 경우 내가 설정해둔 Auditor가 또 실행되는 것인가(updTimd
, updMemberId
속성 값 할당)에 대해 의문을 가지게 되었다.
또한 AuditingEntityListener
클래스의 @PreUpdate
가 적용되어있는 touchForUpdate()
메서드에 디버깅을 찍어 본 결과
CommonUtil.getLoginMemberId()
메서드(find)를 실행할 때 해당 로직을 거친다는 것을 알았다.
Spring Data JPA의 메서드 네이밍 쿼리를 통해 select
할 때 flush()가 유발되고 flush() 발생시 내가 설정해놨던 Auditing이 실행되는 것이였다.
여기서 무한루프가 발생한 것이다.
entity 수정 -> 수정 완료 후 DB 반영 전 auditing 작동 -> 수정자 회원 id 조회시 flush() 이벤트 발생 -> auditing 작동 -> 수정자 회원 id 조회시 flush() 이벤트 발생 -> auditing 작동 -> ...
memberId를 가져오기 위한 쿼리를 JPQL로 작성하여도 같은 결과였고 아래와 같이 jdbcTemplate을 사용하였더니 무한루프에 빠지지 않았다.
jdbcTemplate은 select 쿼리 실행시 flush() 이벤트를 유발하지 않는 것 같다.
근본적인 해결책은 아닌 것 같다는 생각이 든다. 보통은 select쿼리가 아닌 서버에 저장된 인증 객체에서 식별자를 가져와 사용하는 것으로 알고 있는데 이와 같이 수정해야 할듯 싶다.
이 글을 쓴 사람도 나와 같은 상황을 겪은 것 같다.