JPA Auditing 설정 & 무한 Callback

wujin·2023년 4월 11일
0

JPA Auditing

JPA(Java Persistence API)를 사용하여 엔티티 변경 이벤트를 자동으로 감지하고, 감지된 이벤트에 대한 정보를 저장하는 기능이다.

일반적으로 데이터베이스에서는 데이터 변경 이력을 추적하고자 할 때 트리거나 로그 테이블을 이용해 구현한다. 그러나 JPA Auditing을 사용하면 엔티티 객체의 변경 이벤트를 감지하여 이벤트에 대한 정보를 엔티티 객체 자체에 저장하거나, 별도의 변경 이력 테이블에 저장할 수 있다.

JPA Auditing은 @CreatedDate, @LastModifiedDate와 같은 어노테이션을 사용하여 엔티티 객체의 생성 시간과 마지막 수정 시간을 자동으로 기록할 수 있다. 또한, @CreatedBy와 @LastModifiedBy 어노테이션을 사용하여 엔티티를 생성하거나 수정한 사용자 정보를 자동으로 기록할 수 있다.

이러한 기능을 사용하면 애플리케이션에서 데이터 변경 이력을 추적하는 데 유용하며, 보안 감사 추적, 데이터 무결성 확인 등 다양한 목적으로 활용할 수 있다.


Auditing 설정 방법

1. SpringBoot 설정 클래스에 @EnableJpaAuditing 추가

@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@SpringBootApplication
public class TodolistApplication {

	public static void main(String[] args) {
		SpringApplication.run(TodolistApplication.class, args);
	}

}

2. Auditing을 할 필드를 갖는 기본 엔티티 생성

@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;
}
  • @EntityListeners
    엔티티를 DB에 적용하기 전, 이후에 커스텀 콜백을 요청할 수 있는 어노테이션이다. @EntityListeners의 인자로 커스텀 콜백을 요청할 클래스를 지정해주면 되는데, Auditing 을 수행할 때는 JPA 에서 제공하는 AuditingEntityListener.class를 인자로 넘기면 된다.
    아래와 같이 @PrePersist 어노테이션으로 JPA 의 Auditing 기능을 Spring Data JPA 가 사용하게 되는 것이다.

  • @MappedSuperClass
    엔티티의 공통 매핑 정보가 필요할 때 주로 사용한다. 즉, 부모 클래스(엔티티)에 필드를 선언하고 단순히 속성만 받아서 사용하고싶을 때 사용하는 방법이다.
    BaseEntity를 생성하고 Auditing 기능이 필요한 엔티티 클래스에서 사용할 것이기 때문에 @MappedSuperClass 어노테이션을 사용하는 것이다.

  • @CreatedDate
    해당 엔티티가 생성될 때, 생성하는 시각을 자동으로 삽입해준다.

  • @CreatedBy
    해당 엔티티가 생성될 때, 생성하는 사람이 누구인지 자동으로 삽입해준다. 생성하는 주체를 지정하기 위해서 AuditorAware<T> 를 지정해야 한다.

  • @LastModifiedDate
    해당 엔티티가 수정될 때, 수정하는 시각을 자동으로 삽입해준다.

  • @LastModifiedBy
    해당 엔티티가 수정될 때, 수정하는 주체가 누구인지 자동으로 삽입해준다. 생성하는 주체를 지정하기 위해서 AuditorAware<T> 를 지정해야 한다.

3. JPA Auditing Config

@Configuration
public class JpaAuditConfiguration {

    @Bean
    public AuditorAware<Long> auditorProvider() {
        return new AuditorAwareImpl();
    }
}

4. 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 어노테이션이 적용된 필드가 자동으로 저장, 수정된다.


무한 CallBack...

@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() 메서드는 아래와 같다.

getLoginMemberId()

public static Long getLoginMemberId() {
    MemberDTO memberDTO = memberService.getMyMemberWithAuthorities();
    return memberDTO.getMemberId();
}

MemberDTO

@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();
   }
}

getMyMemberWithAuthorities()

@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);
}

MemberRepository() -> findOneWithAuthoritiesByEmail(String email)

@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 로직을 타서 updMemberIdupdTime 필드의 값을 수정 후 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쿼리가 아닌 서버에 저장된 인증 객체에서 식별자를 가져와 사용하는 것으로 알고 있는데 이와 같이 수정해야 할듯 싶다.

이 글을 쓴 사람도 나와 같은 상황을 겪은 것 같다.

0개의 댓글