본문 바로가기
ORM/Spring Data JPA

EntityListener 다루기

by 코딩하는 랄로 2024. 1. 18.
728x90

EntityListener를 만들어 사용하는 이유

EntityListener는 저번 글에서 봤듯이 기본적으로 Spring Data JPA 가 제공해주는 AuditingEntityListener 클래스를 사용할 수 도 있지만 직접 EntityListenr 클래스를 생성한 후 지정해 줄 수 도 있다. Spring Data JPA에서 제공하는 리스너도 충분히 편리한데 굳이 직접 만들어 사용해야 할까?

 

이 의문에 대한 답을 개인적으로 생각해보았을 때, 직접 만든 EntityListener를 사용하게 된다면 여러 중복된 코드도 줄일 수 있고 특정 엔티티에 따라 서로 다른 동작을 할 수 있게 하는 등 기본적으로 제공해주는 JPA 보다는 조금 더 사용자 맞춤 리스너를 작성할 수 있기 때문이 아닐까...

 

예제 코드를 직접 작성을 해보면서 정말 그러한지 알아보자.

 

 

Entity 와 로직의 분리

먼저 예제에서 사용할 Entity 클래스를 하나 생성해주자.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    private String name;
    @NonNull
    private String address;

    @Column(updatable = false)
    private LocalDateTime createdAt;

    @Column
    private LocalDateTime modifiedAt;
}

 

기본적인 학생 정보를 담기 위한 Entity이다. 컬럼 createdAt은 데이터가 생성된 시간을, modifiedAt은 데이터가 수정된 시간을 담기 위함이다. 해당 컬럼의 시간 값을 초기화하고 변경하기 위해서는 기존에는 Student 클래스 내에서 다음과 같이 엔티티 리스너 어노테이션을 통해 메소드를 작성해주었다.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = AuditingEntityListener.class)
public class Student {
    
    ...

    @PrePersist
    public void initDate() {
        this.createdAt = LocalDateTime.now();
        this.modifiedAt = LocalDateTime.now();
    }

    @PreUpdate
    public void updateDate() {
        this.modifiedAt = LocalDateTime.now();
    }
}

 

이러한 방식의 단점은 무엇일까? 만약, 10개의 Entity 에 모두 createdAt와 modifiedAt 컬럼이 있고 모두 똑같은 로직을 통해 값을 할당해주는 경우를 생각해보자. 그렇게 되면 위의 코드가 10개의 Entity 클래스에서 중복이 되게 된다. 이런 상황에서 로직의 변화가 발생할 경우 모든 Entity 클래스를 돌아다니면 같은 수정 작업을 해주어야 하는 것이다.

 

이러한 성가심을 방지하기 위해 여러 방법을 사용할 수 있지만, 이번 글에서는 EntityListener 클래스를 생성해줌으로 해결해 보겠다. 아래와 같이 EntityListener 클래스를 생성해주자.

public class StudentEntityListener {

    @PrePersist
    public void initDate(Object o) {
        if(o instanceof Student) {
            ((Student)o).setCreatedAt(LocalDateTime.now());
            ((Student)o).setModifiedAt(LocalDateTime.now());
        }
    }

    @PreUpdate
    public void updateDate(Student student) {
        student.setModifiedAt(LocalDateTime.now());
    }

}

 

그리고 Student 클래스에 EntityListeners 어노테이션으로 해당 클래스를 Listener로 등록하자. ( @PrePersist, @PreUpdate 부분 삭제!! )

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = StudentEntityListener.class)
public class Student {
    
    ...

}

 

이렇게 Entity에서 Listener로 특정 클래스를 등록하게 되면, 등록된 클래스 내에서도 Entity Event annotation을 사용할 수 있게 된다. 이 때 주의할 점은 해당 어노테이션으로 지정된 메소드는 Object 타입의 매개변수가 필요하다!!!

 

만약 해당 리스너를 호출하는 객체가 무엇인지 정확히 알고 있다면 updateDate() 메소드 처럼 매개변수를 해당 타입으로 지정해주어도 되고, 모를 경우 initDate() 메소드처럼 일단 Object로 받은 다음 if문과 instanceof 연산자를 사용하여 로직을 처리하면 된다.

 

추후에 확장을 고려하여 코드를 작성한다고 한다면, initDate() 메소드의 방법을 사용하는 것이 좋다.

 

 

interface의 활용

EntityListener 를 생성해줌으로 해당 클래스를 리스너로 등록하기만 하면 따로 createdAt, modifiedAt에 관련한 로직을 구현하지 않아도 되고, 로직을 변경해야 하는 상황에도 리스너 클래스 하나만 변경해 주면 된다.

 

하지만, 아직 코드를 개선해야 할 부분이 더 남아있다. 현재 위의 코드에서 School 엔티티와 Teacher 엔티티가 추가가 되고 두 엔티티 모두 StudentEntityListener를 사용한다면 어떻게 될까?

public class StudentEntityListener {

    @PrePersist
    public void initDate(Object o) {
        if(o instanceof Student) {
            ((Student)o).setCreatedAt(LocalDateTime.now());
            ((Student)o).setModifiedAt(LocalDateTime.now());
        } else if ( o instanceof School ) {
            ((School)o).setCreatedAt(LocalDateTime.now());
            ((School)o).setModifiedAt(LocalDateTime.now());
        } else if ( o instanceof Teacher ) {
            ((Teacher)o).setCreatedAt(LocalDateTime.now());
            ((Teacher)o).setModifiedAt(LocalDateTime.now());
        }
    }

    @PreUpdate
    public void updateDate(Student student) {
        if(o instanceof Student) {
            ((Student)o).setModifiedAt(LocalDateTime.now());
        } else if ( o instanceof School ) {
            ((School)o).setModifiedAt(LocalDateTime.now());
        } else if ( o instanceof Teacher ) {
            ((Teacher)o).setModifiedAt(LocalDateTime.now());
        }
    }

}

 

고작 2개의 Entity가 추가되었을 뿐인데 코드가 훨씬 늘어난 것을 볼 수 있다!! 각각의 instance에 대해서 다르게 동작하여 늘어난 것이면 그러려니 할 수 있지만 모두 같은 동작을 하여 코드 또한 중복되는 것을 볼 수 있다

 

이를 해결하려면 어떻게 해야 할까? 위의 코드처럼 if문을 Entity를 구분하는 용도로 사용하는 것이 아닌 로직(동작)을 구분하는 용도로 사용하여야 한다. 같은 로직을 사용하는 클래스들끼리 묶어버려서 로직에 따라 if문을 나눠주게 되면 코드가 중복되는 것을 피할 수 있는 것이다.

 

그렇다면 이제는 어떻게 클래스들을 묶을까에 대한 고민을 해야 한다. 이에 대한 방법으로 자바의 인터페이스와 다형성을 이용하면 된다!! 같은 로직을 사용하는 클래스들은 같은 인터페이스를 상속 받아서 이 상속 받은 인터페이스를 통해서 if 문을 구분하면 된다!!

 

한번 코드를 작성해보자. 먼저 인터페이스 코드이다.

public interface BasicEntity {
    LocalDateTime getCreatedAt();
    LocalDateTime getModifiedAt();
    void setCreatedAt(LocalDateTime regDate);
    void setModifiedAt(LocalDateTime regDate);
}

 

컬럼에 대한 getter 와 setter는 굳이 선언해주지 않아도 된다. 대게 Entity 클래스에 Data 어노테이션( 또는 Getter, Setter) 을 통해 생성이 되어 있지만 생성이 안 되어 있는 경우가 있을 수 있으니 이를 대비하기 위함이다. ( EntityListener에서 해당 컬럼 값을 수정하기 위해서 필요하기 때문에 )

 

이제 이를 Student 클래스가 상속 받고 EntityListener 클래스도 변경해주면 된다.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = StudentEntityListener.class)
public class Student implements BasicEntity {
    
    ...
    
}

 

Student에서 상속 받은 뒤 EntityListener에서 다음과 같이 타입을 지정해주면 된다.

public class StudentEntityListener {

    @PrePersist
    public void initDate(Object o) {
        if(o instanceof BasicEntity) {
            ((BasicEntity)o).setCreatedAt(LocalDateTime.now());
            ((BasicEntity)o).setModifiedAt(LocalDateTime.now());
        }
    }

    @PreUpdate
    public void updateDate(Object o) {
        if(o instanceof BasicEntity) {
            ((BasicEntity)o).setModifiedAt(LocalDateTime.now());
        }
    }

}

 

이제는 School, Teacher 엔티티가 추가가 되어도 해당 엔티티가 같은 로직으로 초기화 된다면 Listener 클래스를 수정하지 않고 BasicEntity 만 상속 받기만 하면 되는 것이다!! 다른 로직을 공유하는 엔티티들이 등장하게 되면 이와 같은 방식으로 if-else문을 사용하면 된다. 

 

 

 

Listener 클래스 내에서 빈 주입받기

Listener 클래스 내에서는 스프링 컨테이너에 있는 빈을 주입 받지 못한다. 하지만 빈을 주입 받아서 사용해야 하는 경우가 생기기도 하는데 예를 들어 Student 의 history를 저장하는 StudentHistory 엔티티가 있다고 한다면 해당 엔티티에는 student 값이 추가가 되고 수정될 때마다 값이 저장이 되어야 한다.

 

값의 추가, 수정이 데이터베이스의 적용된 직후에 해당 정보를 엔티티에 저장하기 위해서는 리스너를 활용해야 하는데, 그러기 위해서는 리스너에서 StudentHistory 엔티티에 값을 저장하기 위한 Repository 를 사용할 수 있어야 한다.

 

이에 대한 해결책은 외부 클래스의 도움을 받는 것이다. 내부에서는 주입 받을 수 없으니 주입 받을 수 있는 외부 클래스에서 주입 받아서 가져와서 사용하는 것이다!! 이를 알아보기 전에 필요한 몇가지 클래스 파일을 생성하자. 먼저 Student의 History를 저장하기 위한 StudentHistory 클래스이다.

@Data
@NoArgsConstructor
@Entity
@EntityListeners(value = StudentEntityListener.class)
public class StudentHistory implements BasicEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // student 의 PK 값 => FK
    private Long studentId;

    @NonNull
    private String name;
    @NonNull
    private String address;

    @Column(updatable = false)
    private LocalDateTime createdAt;

    @Column
    private LocalDateTime modifiedAt;
}

 

Student와 똑같은 컬럼을 가진다. 다음은 StuentHistory에 정보를 저장하기 위한 Repository 클래스이다.

public interface StudentHistoryRepository extends JpaRepository<StudentHistory, Long> {
}

 

다음은 Student가 저장, 수정 된 후에 동작할 메소드를 가지는 Listener 클래스를 하나 생성하자.

public class StudentHistoryListener {

    @PostPersist
    @PostUpdate
    public void saveHistory(Object o) {
        if(o instanceof Student) {
            // TODO
        }
    }

}

 

Listener 클래스를 생성하였으니 Student 엔티티에서 해당 클래스를 리스너로 등록해야 한다. 리스너는 여러개 등록할 수 있다.

@Data
@NoArgsConstructor
@RequiredArgsConstructor
@Entity
@EntityListeners(value = { StudentEntityListener.class , StudentHistoryListener.class})
public class Student implements BasicEntity {
    
    ...

}

 

필요한 작업은 다 끝났으니 Listener에서 빈을 사용할 수 있도록 해주는 외부 클래스 BeanUtils 클래스를 다음과 같이 생성한다.

@Component  // 빈을 주입 받기 위해!!
public class BeanUtils implements ApplicationContextAware {
    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 생성된 ApplicationContext 를 받아온다.
        BeanUtils.applicationContext = applicationContext;
    }

    // 해당 T 클래스에 맞는 Bean을 ApplicationContext 에서 받아오는 메소드 작성
    public static <T> T getBean (Class<T> clazz) {
        return applicationContext.getBean(clazz);
    }
}

 

클래스가 ApplicationContextAware를 상속 받게 되면 해당 클래스에서 ApplicationContext를 주입 받을 수 있다. 해당 컨텍스트 내에 스프링의 빈 객체가 존재하므로 getBean 메소드를 생성하여 원하는 클래스의 빈 객체를 가져올 수 있게 되는 것이다.

 

이제 이를 이용하여, StudentHistoryListener 클래스의 TODO 부분의 로직을 구현하면 다음과 같다.

public class StudentHistoryListener {

    @PostPersist
    @PostUpdate
    public void saveHistory(Object o) {
        if(o instanceof Student) {
            // Listener 에서 스프링 빈 객체 받아오기
            StudentHistoryRepository studentHistoryRepository = BeanUtils.getBean(StudentHistoryRepository.class);

            Student student = (Student)o;
            
            StudentHistory studentHistory = new StudentHistory();
            studentHistory.setStudentId(student.getId());
            studentHistory.setName(student.getName());
            studentHistory.setAddress(student.getAddress());

            studentHistoryRepository.save(studentHistory);  // INSERT
        }
    }

}

 

이제 잘 동작하는지 테스트 해보자. 테스트 코드는 다음과 같이 작성하였다.

@Test
void test() {
    Student student = new Student("kim", "email@email.com");

    studentRepository.save(student);  // INSERT

    System.out.println("STUDENT : " + student);
    System.out.println("STUDENT HISTORY");
    studentHistoryRepository.findAll().forEach(System.out::println);
}

 

코드를 동작 시켜보면 student를 저장할 때 insert 문이 student 테이블에 한번, studentHistory 테이블에 한번, 총 두 번 발생하는 것을 볼 수 있다.

Hibernate: insert into Student (address,createdAt,modifiedAt,name) values (?,?,?,?)
Hibernate: insert into StudentHistory (address,createdAt,modifiedAt,name,studentId) values (?,?,?,?,?)

 

출력된 결과도 확인 해보면 잘 저장된 것을 확인 할 수 있다.

STUDENT : Student(id=1, name=kim, address=email@email.com, createdAt=2024-01-18T17:17:04.088099300, modifiedAt=2024-01-18T17:17:04.088099300)
STUDENT HISTORY
StudentHistory(id=1, studentId=1, name=kim, address=email@email.com, createdAt=2024-01-18T17:17:04.117021, modifiedAt=2024-01-18T17:17:04.117021)
728x90

'ORM > Spring Data JPA' 카테고리의 다른 글

JPA Auditing  (1) 2024.01.17
기본 CRUD - Create & Update  (0) 2024.01.16
기본 CRUD - DELETE  (1) 2024.01.16
기본 CRUD - READ  (0) 2024.01.16
JPA Repository - 개념  (0) 2024.01.15