Jpa 이벤트를 사용해 일관성 있는 시간 정보 관리하기
현재 진행하고 있는 팀 프로젝트 서비스에서는, 최근에 업데이트된 지도를 메인 화면에 보여주고 있다.
우리 도메인에서 지도에는 사람들이 '핀'을 꽂을 수 있는데,
기존에 관리되는 지도의 '업데이트 일시'는 지도의 생성, 정보 수정 시에만 업데이트되고 있었다.
그래서 오래 전에 만들어진 지도이지만, 방금 핀이 추가되어도 이 지도를 메인 화면에서 빠르게 확인할 수 없었다.
지도 내 핀이 추가되거나, 핀을 수정했을 때에도 지도의 업데이트 일시에 반영될 필요가 있었다.
기존 지도의 updatedAt 외에, 사용자에게 보여줄 정보인 lastPinUpdatedAt 이라는 정보를 추가로 관리하기로 했다.
컬럼의 추가
방법으로는 무엇이 있을까?
1. 지도를 조회할 때, 지도가 가진 핀들을 가져와 가장 최신에 업데이트한 날짜를 계산하기
2. 지도 테이블에 lastPinUpdatedAt 컬럼을 두고, 핀이 추가/변경될 때마다 해당 컬럼을 업데이트하기
쓰기 작업보다 조회 작업이 더 많고, 조회할 때마다 지도가 가진 모든 핀들에 대한 연산을 하는 것은 매우 비효율적이며 성능적으로 손해가 크다고 판단했다.
그래서 2번 방법을 선택하였다.
시점의 차이
처음에는 아주 단순하게, 연관 관계 편의 메서드에, 해당 일시를 업데이트하는 코드를 넣기만 하면 될 것이라 생각했다.
(지도라는 도메인의 개발 용어로 Topic을 사용하고 있다.)
// Topic.class (지도)
public void addPin(Pin pin) {
pins.add(pin);
lastPinUpdatedAt = LocaldateTime.now();
}
하지만 핀의 생성 일시와 토픽의 최근 핀 추가 일시를 비교하는 아래 테스트가 실패했다.
@Test
@DisplayName("핀을 추가하면 토픽에 핀의 변경 일시를 새로 반영한다.")
void save_Success_UpdateLastPinAddedAt() {
// given when
long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest);
Pin pin = pinRepository.findById(pinId)
.orElseGet(Assertions::fail);
// then
topicRepository.findById(createRequest.topicId())
.ifPresentOrElse(
topic -> assertThat(topic.getLastPinUpdatedAt()).isEqualTo(pin.getCreatedAt()),
Assertions::fail
);
}
각 엔티티의 createdAt, updatedAt을 저장할 때는 JpaAuditing 기능이 제공하는 AuditingEntityListener를 사용하고 있다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = PROTECTED)
@Getter
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
protected BaseTimeEntity(
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
이 때, 해당 시간 정보들은 객체를 영속화 할 때 설정된다.
그래서 위와 같은 방식으로 구현하면
Pin의 createdAt은 Pin을 영속화하는 시점에 설정되지만,
Topic의 lastPinUpdatedAt은 영속화 시점이 아니라 addPin 메서드를 호출할 때 설정된다.
논리적으로 두 값은 같은 시간이어야 하지만, 이로 인해 서로 미묘하게 다른 문제가 발생하는 것이다.
시간을 저장하는 시점을 일관적으로 관리할 필요가 있다고 판단했다.
우리는 느끼는 미묘한 차이더라도, 분명히 다른 값이라면 문제가 있다.
미묘한 차이가 있어서 테스트가 실패하기 때문에, 시간에 관한 테스트를 제외한다면
실제로 미묘한 차이가 아니라 큰 문제가 생겼을 때에도 이를 빠르게 확인할 수 없을 것이다.
해결
lastPinUpdatedAt 또한, Topic이 영속화되는 시점에 설정되도록 하였다.
어떻게 하면 createdAt, updatedAt을 설정하는 것과 같이 동작하도록 할까? 생각하다보니
AuditingEntityListener가 동작하는 방식을 찾아보게 됐다.
AuditingEntityListener는 @PrePersist, @PreUpdate를 사용해 동작한다.
@PrePersist, @PreUpdate은 Jpa에서 제공하는 이벤트이다.
Pin 엔티티에서도 이를 활용하도록 했다.
// Pin.class
@PrePersist // 엔티티를 영속화할 때(save) 메서드가 실행
protected void prePersist() {
topic.updateLastPinUpdatedAt(getUpdatedAt()); // pin의 updatedAt으로 맞춰줌
}
@PreUpdate // flush나 commit을 호출해 데이터 베이스 업데이트를 할 때 메서드가 실행
protected void preUpdate() {
topic.updateLastPinUpdatedAt(getUpdatedAt()); // pin의 updatedAt으로 맞춰줌
}
이를 통해, 비즈니스 로직에서 별도로 시간 정보를 세팅해주는 일에 신경쓸 필요 없이
객체의 생성 주기에 맞추어 필요한 시간 정보를 관리할 수 있게 되었다.
참고 자료
JPA Auditing과 제대로 알아야 할 @PreUpdate
[JPA] 리스너 - 엔티티의 생명주기에 따른 이벤트 처리