1. Auditing?

게임 회사에서 근무할 때, 자주 했던 업무 중 하나가 게임 이용자 분들의 CS를 처리하는 일이였다. 가령, "A 아이템을 수령했는데, 인벤토리에 없어요ㅠㅠ"와 같은 문의가 들어오면 DB 테이블에서 A 아이템이 생성된 시각이나 수정된 시각 등을 확인해서, 로그를 살펴볼 시간을 대략적으로 추측하고 상세한 플레이 로그를 한 번 더 확인하는 식이다. 이렇듯 서비스를 운영할 때 데이터가 생성되고, 수정된 시각을 기록하고 트래킹하는 것은 중요하다.
Audit은 '검사하다', '감사하다'는 의미를 가지고 있다. 스프링 데이터 JPA에서는 Auditing이라는 기능을 사용해서 엔티티가 생성되고, 변경되는 그 시점을 감지해 생성 시각, 수정 시각, 생성한 사람, 수정한 사람을 기록할 수 있다.
직접 리스너*를 등록하여 사용할 수도 있겠지만, 자주 사용하는 기능이다보니 이미 잘 구현되어 있는 듯하다. 바로 AuditingEntityListener이다. 프로젝트에 적용하는 방법을 알아보자!
Auditing :: Spring Data JPA
Spring Data provides sophisticated support to transparently keep track of who created or changed an entity and when the change happened.To benefit from that functionality, you have to equip your entity classes with auditing metadata that can be defined eit
docs.spring.io
* 리스너: JPA의 리스너 기능을 사용하면 엔티티의 생명주기에 따른 이벤트를 처리할 수 있다.
@EnableJpaAuditing
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
우선 @EnableJpaAuditing 애노테이션을 사용해, Auditing을 활성화해야한다. Application 클래스에 붙이는 방법도 있지만, 특정 기술 Spec에 대한 Configuration 처리는 이렇게 메인에 붙이지 않고 별도의 Class를 만들어서 처리하는 게 나중에 더 관리가 쉽다.
@SpringBootApplication
@EnableJpaAuditing
public class SampleApplication {
public static void main(String[] args) {
SpringApplication.run(SampleApplication.class, args);
}
}
따라서, 다음과 같이 별도의 클래스를 만들어 @Configuration 애노테이션과 함께 사용해주도록 한다.
JpaConfig
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
2. BaseEntitiy로 분리하기 + 엔티티 코드 작성
생성 시각과 수정 시각은 대부분의 엔티티에서 사용되는 필드이다. 따라서, 이를 별개의 엔티티 클래스로 분리하고 다른 엔티티에서는 상속을 받아 사용하면 많은 중복 코드를 없앨 수 있다.
TimeBaseEntity
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class TimeBaseEntity {
@CreatedDate
@Column(name = "created_at", updatable = false)
protected LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
protected LocalDateTime updatedAt;
}
- 이 클래스는 직접 생성해서 사용할 일이 거의 없으므로 추상 클래스로 만드는 것이 권장된다.
- BaseEntity 대신 TimeBaseEntity를 사용하여 생성 및 수정 시각과 관련되었음을 명료하게 표현하고자 했다.
@MappedSuperclass
import jakarta.persistence.MappedSuperclass;
이 애노테이션은 부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속받는 자식 클래스에게 매핑 정보만 제공하고 싶을 때 사용한다. @MppedSuperclass는 비유를 하자면 추상 클래스와 비슷한데, @Entity는 실제 테이블과 매핑되지만 @MappedSuperclass는 실제 테이블과는 매핑되지 않는다. 이것은 단순히 매핑 정보를 상속할 목적으로만 사용된다.
💡 참고
엔티티(@Entity)는 엔티티(@Entity)이거나 @MppedSuperclass로 지정한 클래스만 상속받을 수 있다.
@EntityListeners
import jakarta.persistence.EntityListeners;
이 애노테이션은 엔티티의 변화를 감지하여, 엔티티와 매핑된 테이블의 데이터를 조작한다.
이 애노테이션의 파라미터에 리스너를 넣어줘야하는데, 여기에 AuditingEntityListener.class를 넣어준다.
이 클래스는 스프링 데이터 JPA에서 제공하는 리스너로 엔티티의 영속, 수정 이벤트를 감지하는 역할을 한다.
@CreatedDate
import org.springframework.data.annotation.CreatedDate;
생성 시각을 기록하기 위해 LocalDateTime 타입 필드에 @CreatedDate를 적용한다. 이렇게 적용하면, 엔티티가 생성될 때 감지되어 그 시점을 createdAt 필드에 기록한다. 생성일의 경우 수정되어서는 안되므로 @Column(updatable = false)를 추가로 적용했다.
@LastModifiedDate
import org.springframework.data.annotation.LastModifiedDate;
수정 시각을 기록하기 위해 LocalDateTime 타입 필드에 @LastModifiedDate를 적용한다. 이렇게 적용하면, 엔티티가 수정될 때 감지되어 그 시점을 updatedAt 필드에 기록한다.
💡 참고
@CreatedBy: 작성자
@LastModifiedBy: 수정자
엔티티 코드 작성
@Entity
@Getter
public class Movie extends TimeBaseEntity {
@Id
@GeneratedValue
@Column(name = "movie_id", updatable = false)
private Long id;
@Column(nullable = false)
private String title;
private String director;
private String summary;
@Column(name = "image_url")
private String imageUrl;
// ... 코드 생략 ...
}
3. 테스트 코드 작성 및 확인
@SpringBootTest
@Transactional // 트랜잭션 안에서 테스트를 실행한다
class MovieTest {
@Autowired
MovieRepository movieRepository;
@Test
void checkBook() {
// 생성 시각 확인
Movie savedMovie = movieRepository.save(new Movie("제목제목", "감독", "줄거리", "111"));
Movie createdMovie = movieRepository.findById(savedMovie.getId()).orElseThrow(EntityNotFoundException::new);
LocalDateTime firstCreatedAt = createdMovie.getCreatedAt();
LocalDateTime firstUpdatedAt = createdMovie.getUpdatedAt();
System.out.println("firstCreatedAt: " + firstCreatedAt);
System.out.println("firstUpdatedAt: " + firstUpdatedAt);
// 수정 시각 확인
savedMovie.updateEntity("뉴제목제목", "뉴감독", "뉴줄거리", "222");
movieRepository.flush(); // 변경 감지 적용 (수정 쿼리 적용)
Movie updatedMovie = movieRepository.findById(savedMovie.getId()).orElseThrow(EntityNotFoundException::new);
LocalDateTime secondCreatedAt = updatedMovie.getCreatedAt();
LocalDateTime secondUpdatedAt = updatedMovie.getUpdatedAt();
System.out.println("secondCreatedAt: " + secondCreatedAt);
System.out.println("secondUpdatedAt: " + secondUpdatedAt);
// 생성 시각은 같고, 수정 시각은 달려졌는지 확인
assertThat(firstCreatedAt).isEqualTo(secondCreatedAt);
assertThat(firstUpdatedAt).isNotEqualTo(secondUpdatedAt);
}
}
테스트를 통해 수정 시각이 달라졌음을 확인할 수 있었다.

