본문 바로가기
Spring/Spring Data JPA

Spring Data JPA - Auditing

by WangTak 2021. 11. 28.
반응형

오늘은 Spring Data JPA에서 제공하는 유용한 기능인 Auditing에 대해서 정리해보려고 합니다.

 

백엔드 그리고 DB 쪽 개발을 하다 보면 테이블에 공통적으로 들어가는 Column이 있습니다.

  • 등록한 날짜
  • 마지막으로 수정한 날짜
  • 등록한 사람
  • 마지막으로 수정한 사람

 

그래서 Spring Data JPA에서는 테이블에 들어가야 하는 위와 같은 필수적인 Column을 쉽게 사용할 수 있도록 Auditing이라는 기능을 제공합니다.

 

[순수한 JPA를 사용했을 때의 방법][Spring Data JPA에서 제공하는 방법] 2가지로 나누어서 정리하겠습니다.

 

프로젝트 버전

  • 개발 도구: IntelliJ Ultimate
  • Spring Boot: 2.5.7
  • Java 11
  • h2 Database
  • Spring Data JPA 의존성 추가 [build.gradle]

 

Auditing을 적용할 Member Entity

Member

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;
    private String email;
    private String name;
    private String password;

    private Member(String email, String name, String password) {
        this.email = email;
        this.name = name;
        this.password = password;
    }

    public static Member createMember(String email, String name, String password) {
        return new Member(email, name, password);
    }
}

Auditing을 적용할 간단한 Member Entity를 만듭니다. [순수 JPA], [Spring Data JPA] 2가지 모두 Member Entity로 예시를 들겠습니다.

 

순수 JPA

Auditing을 위한 JpaBaseEntity라는 이름을 가진 클래스를 만들어줍니다.

JpaBaseEntity

@Getter
@MappedSuperclass
public abstract class JpaBaseEntity {

    @Column(updatable = false)
    private LocalDateTime createdDate; // 등록한 날짜
    private LocalDateTime updatedDate; // 마지막으로 수정한 날짜

    @PrePersist
    public void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        createdDate = now;
        updatedDate = now;
    }

    @PreUpdate
    public void preUpdate() {
        updatedDate = LocalDateTime.now();
    }
}
  • @PrePersist: JPA에서 persist가 날아가기 전에 수행되도록 하는 어노테이션
  • @PreUpdate: JPA에서 update가 되기 전에 수행되도록 하는 어노테이션
@MappedSuperclass란?
상속관계로 엮이지도 않고, 엔티티도 아니며, 테이블과 매핑되지도 않습니다. 그저 @MappedSuperclass를 가지고 있는 클래스의 멤버 필드들이 상속받은 자식 클래스에 쏙 하고 들어간다고 생각해주시면 될 것 같습니다.
단순히 엔티티가 공통으로 사용하는 매핑 정보[등록한 날짜, 마지막으로 수정한 날짜 etc..]를 모으는 역할을 합니다.

 

Member Entity에 방금 만든 JpaBaseEntity를 상속합니다.

 

Member

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends JpaBaseEntity {
	/* ... */
}
참고: @Entity 클래스는 또 다른 엔티티나 @MappedSuperclass로 지정한 클래스만 상속 가능합니다.

 

테스트 코드를 작성하여 제대로 적용이 되는지 확인해봅시다.

 

AuditingTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class AuditingTest {

    @PersistenceContext
    private EntityManager em;

    @Test
    public void basicJpaAuditingTest() throws Exception {
        String email = "wangtak@gmail.com";
        String name = "왕탁이";
        String password = "1234"; // 실무에서는 단방향 해싱 후 저장해야 됩니다.

        Member member = Member.createMember(email, name, password);

        em.persist(member);
        em.flush();
    }
}

이메일, 이름, 비밀번호를 사용하여 Member 객체를 만든 후 member를 저장하면 아래와 같은 쿼리문이 나가는 것을 확인할 수 있습니다.

 

Test 실행 후 쿼리 결과

Member Entity에는 email, name, password만 적어줬는데, 수행된 쿼리문을 보면 created_date, updated_date가 있음을 알 수 있습니다. 이는 Member Entity가 @MappedSuperclass를 가진 JpaBaseEntity를 상속받았기에 컬럼이 추가된 것입니다.
또한, @PrePersist 어노테이션이 있기 때문에 persist가 되기 전에 어노테이션이 붙어있는 prePersist() 메서드가 실행되어 각각의 멤버 변수[createdDate, updatedDate]를 현재 시간으로 값을 설정했고, 그에 맞게 쿼리가 나가는 것을 확인할 수 있습니다.
참고: @PrePersist 어노테이션을 주석 처리하고 테스트 코드를 실행해보면 null 값으로 들어갑니다.

 

DB에 정상적으로 저장

 

참고: JPA는 Transaction 단위(DB의 Transaction과 다름)로 수행되기 때문에 테스트 코드에 @Transactional이 필요합니다.
참고: 테스트 코드는 기본적으로 Rollback을 하기 때문에 @Transactional을 붙여 준 후에 테스트를 실행하여도 정상적으로 동작(쿼리 정상적으로 수행)하긴 하지만 DB에는 값이 없습니다. 만약 테스트 코드에서 수행한 값을 저장하여 DB에서 확인해보고 싶다면 @Rollback(value = false) 사용하여 Rollback을 안 하도록 설정해주면 됩니다.

[순수 JPA를 사용했을 경우 경우 자체적으로 @Trasactional을 가지고 있지 않기 때문에 @Transactional을 붙여주고 그에 따라 테스트 코드에서는 Rollback이 일어나지만, Spring Data JPA를 사용했을 경우 JPA 내부 구현체에 @Transactional을 가지고 있기 때문에 별도로 Rollback이 일어나지 않습니다.]

 

그럼 다음으로는 Spring Data JPA에서 Auditing 기능을 사용해보겠습니다.

Member Entity에 상속했던 JpaBaseEntity는 제거해주세요.

 

Spring Data JPA

Spring Data JPA에서 제공하는 Auditing을 사용하기 위해서는 먼저 설정이 필요합니다.

 

스프링 부트 설정 클래스[main 함수가 있는 진입점 클래스]에 @EnableJpaAuditing을 적용해줍니다.

AuditingApplication

@EnableJpaAuditing // Auditing 기능 활성화
@SpringBootApplication
public class AuditingApplication {

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

}

 

이번에는 등록한 날짜, 마지막으로 수정한 날짜, 등록한 사람, 마지막으로 수정한 사람 이렇게 총 4개의 필드를 사용해보겠습니다.

 

등록한 날짜, 마지막으로 수정한 날짜는 대부분의 엔티티에 필요하지만, 등록한 사람, 마지막으로 수정한 사람은 설계에 따라 필요하지 않을 수도 있습니다. 그래서 2개의 Base 타입을 분리하여 상황에 맞는 타입을 선택하여 상속하도록 하겠습니다.

 

  • BaseTimeEntity: 등록한 날짜, 마지막으로 수정한 날짜
  • BaseEntity: BaseTimeEntity를 상속하여 필드에 등록한 사람, 마지막으로 수정한 사람

 

BaseTimeEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

 

BaseEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity extends BaseTimeEntity {

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String lastModifiedBy;
}

 

@EntityListeners: JPA Entity에 특정 이벤트(AuditingEntityListener)가 발생했을 때 콜백을 처리하도록 하는 어노테이션

 

따로 메서드를 만들어서 그 위에 어노테이션을 적는 것 대신에 멤버 필드 위에 바로 어노테이션을 적어줌으로써 순수 JPA와 비교했을 때 코드가 좀 더 간략해졌습니다.
BaseTimeEntity, BaseEntity 엔티티 모두 추상 클래스(abstract class)로 선언한 이유는?
두 엔티티 모두 @Entity 클래스에 상속하여 2개의 엔티티가 가지고 있는 멤버 변수를 통해 Auditing을 하도록 만든 객체이기 때문에 추상 클래스(abstract class)로 선언함으로써 다른 곳에서 인스턴스를 생성하지 못하도록 붙여 놓은 것입니다. 

 

Member Entity에 BaseEntity를 상속해줍니다.

 

Member

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
	/* ... */
}
만약 시간 정보(등록한 날짜, 마지막으로 수정한 날짜)만 넣고 싶으면 BaseTimeEntity를 상속해 주시면 됩니다.

 

여기서 추가적으로 Bean을 등록하여 Entity를 등록한 사람, 마지막으로 수정한 사람을 처리해주는 설정을 하겠습니다. @EnableJpaAuditing을 설정해주었던 스프링 부트 설정 클래스에 아래 Bean을 등록해줍니다.

 

AuditingApplicaiton

@Bean
public AuditorAware<String> auditorProvider() {
	return () -> Optional.of(UUID.randomUUID().toString());	
}
실무에서는 Spring Security를 사용하기 때문에 SecurityContext에서 로그인 한 사용자의 ID를 가져오면 됩니다. 지금은 공부를 위해서 무작위 값을 세팅하도록 하였습니다. Auditing 정상적으로 적용되었는지 확인 후에 Spring Security를 적용한 Auditing 예제 코드를 설명하겠습니다.

 

이제 Spring Data JPA에서 제공하는 Auditing 기능을 사용할 수 있습니다. 그럼 테스트 코드로 확인을 해보도록 하겠습니다. 먼저 Spring Data JPA를 사용하기 위한 Repository를 만들어줍니다.

 

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {
}

 

AuditingTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class AuditingTest {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void dataJpaAuditingTest() throws Exception {
        String email = "wangtak@gmail.com";
        String name = "왕탁이";
        String password = "1234";

        Member member = Member.createMember(email, name, password);

        memberRepository.save(member);
    }
}

순수 JPA에서 사용했던 코드와 유사하지만 Spring Data JPA Repository를 사용하여 Entity를 저장하는 것에 차이가 있습니다.

 

실행된 query의 결과가 매우 길어서 값이 들어간 결과는 DB 사진으로 첨부하겠습니다.

Spring Data JPA Auditing

 

DB에 정상적으로 저장

 

순수 JPA, Spring Data JPA에서 Auditing 기능 사용에 대해 알아봤습니다.

 

참고: 영한님 Comment
저장 시점에 등록일, 등록자는 물론이고, 수정일, 수정자도 같은 데이터가 저장된다. 데이터가 중복 저장되는 것 같지만, 이렇게 해두면 변경 컬럼만 확인해도 마지막에 업데이트한 사용자를 확인할 수 있으므로 유지보수 관점에서 편리하다. 이렇게 하지 않으면 변경 컬림이 null일 때 등록 컬럼을 또 찾아야 한다.

 

[Next Step] Spring Security + Auditing 적용

이건 실무에서 제가 Spring Security에서 AuditorAware를 적용하려고 했을 때 좀 고생을 했기에, 잊지 말자고 추가적으로 정리합니다.

 

AuditingApplication

@Bean
public AuditorAware<String> auditorProvider() {
	return () -> Optional.ofNullable(SecurityContextHolder.getContext())
		.map(SecurityContext::getAuthentication)
		.filter(authentication -> !authentication.getPrincipal().equals("anonymousUser"))
		.map(Authentication::getPrincipal)
		.map(SessionMember.class::cast)
		.map(SessionMember::getEmail);
}

SecurityContextHolder에서 Context를 찾아서 로그인한 유저의 정보를 찾는 것인데, 저희 서비스에서 사용자가 비밀번호를 까먹어서 임시 비밀번호를 이메일로 전송해주는 것이 있었는데, 그때 임시 비밀번호를 발급하면서 Member Entity의 password가 update가 일어나서 Auditing이 발생합니다. 그런데, 익명 사용자에 대한 처리를 해두지 않아서 오류가 났었습니다.. 위와 같이 임시방편으로 처리하여 익명 사용자일 경우에는 필터링을 하여 auditing이 되지 않도록 하였습니다.

 

SessionMember 클래스는 Session에 저장할 정보를 가지고 있는 클래스입니다. Spring Security는 Session + Cookie 방식이기 때문에 SessionMember 같은 세션에 저장할 클래스가 필요했습니다.
그냥 Member Entity를 사용하면 안 됐나요? Session에 모든 데이터를 넣게 되면 사용자가 기하급수적으로 늘어났을 경우 Session에 메모리가 빠르게 차 버리기 때문에 fit 하게 맞춘 필요한 정보로만 새로운 클래스를 만들었습니다.
Spring Security + Redis + Session Clustering은 향후에 정리해보도록 하겠습니다.
필터링을 하는데 뭔가 더 괜찮은 방법이 있지 않을까... ㅠㅠ
다만 authentication.isAuthenticated() 함수를 사용하면 뭔가 깔끔할 것 같은데 로깅을 해보면 결과 값이 익명 사용자임에도 불구하고 true로 나왔습니다.. ㅠ

 

지금까지 순수 JPA, Spring Data JPA, Spring Security + Auditing 적용에 대한 경험을 정리해봤습니다.

 

반응형