Entity Listener는 엔티티의 변화를 감지하고 테이블의 데이터를 조작하는 일을 한다.
컬럼값이 추가되거나 수정되는 것에 대해 반복적인 코드를 계속 작성해 처리하는 것을 개선할 수 있게 해준다.
Entity Listener에는 7가지 이벤트가 있다.
- @PrePersist
- @PostPersist
- @PreUpdate
- @PostUpdate
- @PreRemove
- @PostRemove
- @PostLoad
목록을 대충 봐도 Pre와 Post로 구분이 된것을 볼 수 있다.
Pre의 경우는 메소드 호출되기 전, Post는 메소드가 호출된 이후에 실행되도록 한다.
Persist는 insert를 의미한다.
그래서 @PrePersist는 persist 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고
@PostPersist는 persist 메소드가 호출되어 실행된 직후에 실행하도록 하는 이벤트이다.
Update는 의미 그대로 merge 메소드의 호출 시점에 따른 이벤트다.
@PreUpdate는 merge 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트이고
@PostUpdate는 merge 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.
Remove는 delete 메소드의 호출시점에 따른 이벤트다.
@PreRemove는 delete 메소드가 호출되어 실행되기 전에 실행되도록 하는 이벤트,
@PostRemove는 delete 메소드가 호출되어 실행된 직후 실행되도록 하는 이벤트다.
마지막으로 @PostLoad는 select 조회가 일어난 직후에 실행되는 메소드이다.
이 시점들을 확인해보기 위해 테스트 코드를 작성.
정리전 예제 환경은 아래와 같다.
- Intelli J
- SpringBoot 2.6.2
- Lombok
- Gradle 7.3.2
-- data.sql
call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(1, 'coco', 'coco@gmail.com', now(), now());
call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(2, 'mozzi', 'mozzi@gmail.com', now(), now());
call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(3, 'coco2', 'coco2@gmail.com', now(), now());
call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(4, 'mozzi2', 'mozzi2@gmail.com', now(), now());
call next value for hibernate_sequence;
insert into user(`id`, `name`, `email`, `created_at`, `updated_at`) values(5, 'coco3', 'coco3@gmail.com', now(), now());
public enum Gender {
MALE,
FEMALE
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
@NonNull
private String name;
@NonNull
private String email;
@Column(updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Enumerated(value = EnumType.STRING)
private Gender gender;
@Prepersist
public void prePersist(){
System.out.println(">>>>>> prePersist");
}
@PostPersist
public void postPersist(){
System.out.println(">>>> postPersist");
}
@PreUpdate
public void preUpdate(){
System.out.println(">>>>> preUpdate");
}
@PostUpdate
public void postUpdate(){
System.out.println(">>>>>> postUpdate");
}
@PreRemove
public void preRemove(){
System.out.println(">>>> preRemove");
}
@PostRemove
public void postRemove(){
System.out.println(">>>>> postRemove");
}
@PostLoad
public void postLoad(){
System.out.println(">>>> postLoad");
}
}
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void listenerTest(){
User user = new User();
user.setEmail("coco1@gmail.com");
user.setName("coco1");
userRepository.save(user); //persist 동작 시점
User user2 = userRepository.findById(1L).orElseThrow(RuntimeException::new); //select 시점
user2.setName("coooocoooo");
userRepository.save(user2); //merge 동작 시점
userRepository.deleteById(4L); //delete 동작 시점
}
}
테스트 코드로 실행 순서를 보면
persist(insert) -> select -> merge(update) -> delete 순서로 진행된다.
그럼 테스트를 실행하기 전 시점을 미리 예상해보자면
PrePersist -> persist -> PostPersist -> select -> PostLoad -> PreUpdate -> merge -> PostUpdate -> PreRemove
-> delete -> PostRemove 순서로 나오게 될것이다.
그럼 이제 테스트를 실행해서 보면
제일먼저 prePersist가 출력된다.
그 후 insert 쿼리가 실행이 되고 PostPersist가 찍힌다.
그리고 select 쿼리가 실행된 후에 postLoad가 찍히고 select 쿼리가 한번 더 찍히는데 이건 save메소드에서 update 전 존재 여부때문에 찍어보는것이다. 이 쿼리문 이후에 postLoad가 한번 더 찍힌다.
다음으로는 preUpdate 출력되고 update 쿼리 실행 이후 postUpdate가 출력되었고
delete 이전 존재여부에 대한 select 조회 이후 postLoad가 한번 더 찍힌 후에 바로 preRemove 출력 후에
delete 쿼리가 실행되고 postRemove가 찍히는 것을 볼 수 있다.
이렇게 해당 쿼리 실행을 기준으로 이전 이후로 나누어져 이벤트가 호출되어 실행되는 것을 볼 수 있다.
그럼 이런 이벤트를 어디서 쓸것이고 어떻게 쓸것인지를 본다.
일단 복잡하진 않지만 간단한 예제로 테스트를 한다.
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void prePersistTest(){
User user = new User();
user.setEmail("coco1@gmail.com");
user.setName("coco1");
user.setCreatedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
userRepository.save(user);
}
}
보통 회원 정보에 대해 저장을 하게 되면 아이디, 이름, 성별, 이메일 등의 정보는 사용자가 입력한 정보를 받아 추가해주거나 수정하게 된다.
그럼 그 정보들은 set을 통해 처리해야 한다고 볼 수 있다.
하지만 추가된 시간과 수정된 시간을 넣어주는 createdAt과 updatedAt은??
이건 사용자가 직접 입력하는 시간이 아닌 이 코드가 처리되는 시점의 현재시간을 그냥 넣어주기만 하면 된다.
그럼 매번 처리 코드를 짤때마다 set으로 넣어줘야 한다.
문제는 없지만 중복되는 코드가 너무 많고 시간을 잘못 입력한다거나 빼먹는다거나 하는 실수가 발생할 수도 있다.
그래서 이런 상황에서 엔티티 리스너를 활용해 처리하면 편해진다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
@NonNull
private String name;
@NonNull
private String email;
@Column(updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Enumerated(value = EnumType.STRING)
private Gender gender;
@Prepersist
public void prePersist(){
System.out.println(">>>>>> prePersist");
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate(){
System.out.println(">>>>> preUpdate");
this.updatedAt = LocalDateTime.now();
}
}
이렇게 처리해주게 되면 persist나 merge 동작전에 createdAt과 updatedAt이 쿼리실행전 현재 시간을 갖고 있도록 할 수 있다.
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void prePersistTest(){
User user = new User();
user.setEmail("coco1@gmail.com");
user.setName("coco1");
//user.setCreatedAt(LocalDateTime.now());
//user.setUpdatedAt(LocalDateTime.now());
userRepository.save(user);
System.out.println(userRepository.findByName("coco1");
user.setName("cococo1");
userRepository.save(user);
System.out.println(userRepository.findByName("cococo1");
}
}
이렇게 테스트를 실행해보면 첫 출력문에서는 createdAt과 updatedAt이 동일한 시간대가 들어가있는것을 볼 수 있고
마지막 출력문에서는 updatedAt이 수정되어 있는것을 확인할 수 있다.
다음 문제는 이제 엔티티의 개수가 많은데 모든 엔티티에서 createdAt과 updatedAt을 사용하는 경우다.
지금까지 본 구조로는 엔티티마다 @PrePersist와 @PreUpdate 메소드를 만들어 추가해줘야 한다.
그럼 엔티티마다 중복되는 코드가 발생하게 되고 중간에 놓칠 가능성도 있을 뿐더러 나중에 수정해야 하는 상황이 발생하게 되면 너무 많은 엔티티를 수정해야 한다.
이때는 Entity Listener를 지정해서 활용하는 방법으로 처리하면 좋다.
그래서 MyEntityListener라는 클래스를 하나 생성하고 MyEntityListener에서는 createdAt과 updatedAt이 존재해야 한다는 것을 알아야 하기 때문에 인터페이스를 하나 생성해 처리해준다.
그래서 Auditable 이라는 인터페이스를 생성해 그 안에 createdAt과 updatedAt을 처리할 수 있도록 해주면 된다.
public interface Auditable {
LocalDateTime getCreatedAt();
LocalDateTime getUpdatedAt();
void setCreatedAt(LocalDateTime createdAt);
void setUpdatedAt(LocalDateTime updatedAt);
}
public class MyEntityListener {
@PrePersist
public void prePersist(Object o) {
if(o instanceof Auditable) {
((Auditable) o).setCreatedAt(LocalDateTime.now());
((Auditable) o).setUpdatedAt(LocalDateTime.now());
}
}
@PreUpdate
public void preUpdate(Obejct o) {
if(o instanceof Auditable) {
((Auditable) o).setUpdatedAt(LocalDateTime.now());
}
}
}
MyEntityListener에서 두 메소드는 파라미터로 Object를 꼭 받아야 한다.
아무것도 받지 않게 작성하면 Method 'prePersist' should take parameter of type 'object here이라는 오류가 발생한다.
엔티티 객체에서는 파라미터를 받지 않아도 this의 값이기 때문에 object를 확인할 수 있지만 listener는 해당 엔티티를 받아서 처리해야 하고 이 object가 어떤 타입인지 Listener에서 알기 힘들기 때문에 object로 강제화 해야 한다.
그래서 대신 object를 받으면 Auditable 객체 타입인지 확인하는 작업만 추가해 Auditable 객체일때는 createdAt과 updatedAt을 now로 설정하도록 추가해준다.
Auditable의 createdAt과 updatedAt은 각 엔티티에 implements Auditable을 붙여줘 getter와 setter를 받아 처리하도록 해주고 엔티티의 @PrePersist와 @PreUpdate 메소드들은 지워준다.
그리고 엔티티에서 @EntityListener(value = MyEntityListener.class)를 추가해 해당 리스너 클래스를 지정해준다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListener(value = MyEntityListener.class)
public class User implements Auditable{
@Id
@GeneratedValue
private Long id;
@NonNull
private String name;
@NonNull
private String email;
@Column(updatable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@Enumerated(value = EnumType.STRING)
private Gender gender;
}
그리고 이전 테스트 코드인 prePersistTest 메소드를 실행시켜보면 잘 처리되는것을 확인할 수 있다.
이게 이전 코드랑 겹치지는 않았는지 확인을 해보고 싶으면 User 엔티티에서 @EntityListener(value = MyEntityListener.class)를 주석처리하고 테스트를 실행해보면 createdAt과 updatedAt이 null로 나오는것을 볼 수 있다.
null로 나오지 않는다면 어딘가 잘못 설정한것...
지금까지 테스트들 처럼 추가하는 케이스도 있지만 Hitory Data의 경우에는 DB에 특정 데이터가 수정되면 해당 값의 복사본을 다른 테이블에 저장해두는 경우가 있다.
그래서 이 예제는 User 테이블의 데이터가 수정될때 이 복사본을 userHistory 라는 테이블에 저장하도록 할것이다.
UserHistory 엔티티와 UserEntityListener를 생성한다.
UserEntityListener를 생성하는 이유는 User가 생성될때마다 userHistory에도 생성해주기 위함이다.
마지막으로 userHistoryRepository도 추가해준다.
@Entity
@NoArgsConstructor
@Data
@EntityListener(value = MyEntityListener.class)
public class UserHistory implements Auditable {
@Id
@GeneratedValue
private Long id;
private Long userId;
private String name;
private String email;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
public interface UserHistoryRepository extends JpaRepository<UserHistory, Long> {
}
@Component
public class UserEntityListener{
@Autowired
private UserHistoryRepository userHistoryRepository;
@PreUpdate
public void prePersistAndPreUpdate(Object o) {
User user = (User) o;
UserHistory userHistory = new UserHistory();
userHistory.setUserId(user.getId());
userHistory.setName(user.getName());
userHistory.setEmail(user.getEmail());
userHistoryRepository.save(userHistory);
}
}
UserEntityListener에서는 User의 정보를 UserHistoryRepository를 통해 userHistory에 저장해야 하기 때문에
@Autowired로 userHistoryRepository를 주입받아야 한다.
그리고 아래 테스트 코드를 실행해본다.
@SpringBootTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private UserHistoryRepository userHistoryRepository;
@Test
void userHistoryTest(){
User user = new User();
user.setEmail("coco-new@gmail.com");
user.setName("coco-new");
userRepository.save(user);
user.setName("coco-new-new");
userRepository.save(user);
userHistoryRepository.findAll().forEach(System.out::println);
}
}
이 테스트를 실행하게 되면 오류가 발생한다.
nullPointerException이 발생하는데 EntityListener는 Spring Bean을 주입받지 못하기 때문이다.
그런데 지금 UserEntityListener에서 UserHistoryRepository를 주입받으려 했는데 주입받지 못해서 nullPointerException이 발생하게 되는것.
이 문제를 해결하기 위해 BeanUtils라고 클래스를 하나 생성해서 처리하고
UserEntityListener를 수정한다.
@Component
public class BeanUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
BeanUtils.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return applicationContext.getBean(clazz);
//해당 클래스에 맞는 applicationContext에서 getBean을 통해 class에 맞는 bean을 리턴해준다.
}
}
import com.fastcampus.jpa.bookmanager.support.BeanUtils;
public class UserEntityListener{
@PrePersist
@PreUpdate
public void prePersistAndPreUpdate(Object o) {
UserHistoryRepository userHistoryRepository = BeanUtils.getBean(UserHistoryRepository.class);
//BeanUtils는 새로 생성한 클래스 외에도
//org.springframework.beans.BeanUtils가 있기 때문에 import 시에 주의.
User user = (User) o;
UserHistory userHistory = new UserHistory();
userHistory.setUserId(user.getId());
userHistory.setName(user.getName());
userHistory.setEmail(user.getEmail());
userHistoryRepository.save(userHistory);
}
}
User 데이터가 추가될때 history도 같이 추가될 수 있도록 @PrePersist도 같이 붙여준다.
그리고 테스트를 실행.
그리고 로그를 확인해보면 history insert -> user insert -> select(merge 이전 select) -> history insert -> user update
-> select(출력 조회)
이 순서대로 쿼리가 실행되는 것을 볼 수 있다.
그럼 의도한대로 persist 이전, update이전에 history에 추가해주는 것을 확인할 수 있다.
여기서 createdAt과 updatedAt이 User와 UserHistory 엔티티에 중복되어 들어가 있는데 이것도 분리할 수 있다.
이건 다음포스팅에....
Reference
- 패스트캠퍼스 java/spring 초격차 패키지 Spring Data JPA
'Spring' 카테고리의 다른 글
JPA 양방향 연관관계(mappedBy) (0) | 2022.03.18 |
---|---|
JPA Entity Listener 2 (0) | 2022.03.17 |
Jpa Entity 기본 Annotation 2 (@Column, @Transient) (0) | 2022.03.15 |
Jpa Entity 기본 Annotation 1 (@GeneratedValue, @Table) (0) | 2022.03.14 |
JPA Enum 적용 (0) | 2022.02.19 |