데이터 보존과 감사 로그가 필요하다는 요구사항이 있다.
- 데이터 보존: 모든 데이터는 완전 삭제되지 않고 숨김 처리로 관리(숨김과 삭제는 다른 필드에서 동작) → 논리적 삭제
- 데이터 감사 로그: 모든 정보에 생성일, 생성 아이디, 수정일, 수정 아이디, 삭제일, 삭제 아이디를 포함
위와 같은 정보들은 모든 엔티티에 공통으로 들어가야 한다.
간단하게 엔티티마다 해당 정보들을 컬럼으로 넣어준다. 하지만 이렇게 된다면 코드 중복이 우려된다.
그렇다면, 코드 중복을 줄이면서 구현할 방법이 있을까?
BaseEntity
Entity들이 공통적으로 사용하는 속성을 하나로 묶어 BaseEntity라는 부모 클래스를 생성한다.
BaseEntity 클래스를 생성하여 공통 속성을 담고, 엔티티가 상속받을 수 있도록 하면, 공통 속성에 대한 추상화도 가능하며, 코드 중복을 줄일 수 있다.
AuditingEntity
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class AuditingEntity {
@CreatedDate
@Column(name="created_at", updatable = false, nullable = false)
private LocalDateTime createdAt;
@CreatedBy
@Column(name="created_by", updatable = false)
private String createdBy;
@LastModifiedDate
@Column(name="updated_at")
private LocalDateTime updatedAt;
@LastModifiedBy
@Column(name="updated_by")
private String updatedBy;
@Column(name="deleted_at")
private LocalDateTime deletedAt;
@Column(name="deleted_by")
private String deletedBy;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
// 논리적 삭제, 엔티티가 삭제될 때 자동으로 수행
@PreRemove
public void onPreRemove() {
this.deletedAt = LocalDateTime.now();
this.isDeleted = true;
}
}
추상 클래스로 선언한 이유
해당 클래스를 직접 인스턴스화하여 사용할 이유가 없기 때문에 추상 클래스로 선언한다.
이 클래스가 직접적으로 엔티티로 사용되는 것이 아니라, 다른 엔티티 클래스들이 공통적으로 사용하는 기본적인 필드와 동작을 제공하기 위함이다.
@MappedSuperclass
와 @EntityListeners(AuditingEntityListener.class)
어노테이션은 JPA 엔티티의 동작 방식을 정의한다.
@MappedSuperclass
@MappedSuperclass
는 JPA에서 상속을 통해 공통적으로 사용할 수 있는 엔티티 클래스의 슈퍼클래스를 정의할 때 사용된다.- 이 어노테이션이 적용된 클래스는 실제로 데이터베이스 테이블과 매핑되지 않는다. 이 클래스를 상속받는 자식 엔티티 클래스가 해당 필드들을 자신의 필드로 상속받게 된다.
AuditingEntity
클래스는 테이블과 매핑되지 않지만, 이를 상속받는 엔티티들은createdAt
,createdBy
,updatedAt
,updatedBy
,deletedAt
,deletedBy
,isDeleted
필드들을 포함하게 된다.@MappedSuperclass
로 지정된 클래스 자체는 독립적인 테이블을 생성하지 않는다.- 이 추상 클래스에 정의된 필드들은 상속받은 엔티티에서 사용 가능하며, 해당 엔티티의 테이블에 포함된다.
@EntityListeners(AuditingEntityListener.class)
@EntityListeners
어노테이션은 JPA 엔티티의 라이프사이클 이벤트를 처리할 리스너 클래스를 지정한다.AuditingEntityListener
는 엔티티의 생성, 수정, 삭제 시점에 자동으로 타임스탬프와 사용자 정보를 기록할 수 있게 해주는 리스너다.- 커스텀 리스너도 가능하다.
AuditingEntityListener
대신 사용자 정의 리스너 클래스를 지정하여 엔티티의 특정 이벤트에 대해 커스텀 로직을 수행할 수 있다.
두 어노테이션은 함께 사용될 때 시너지가 발휘되는 듯하다.
이를 통해 엔티티의 감사 정보를 손쉽게 관리할 수 있다.
@MappedSuperclass
로 공통 필드를 정의하고, @EntityListeners
를 통해 엔티티가 생성되거나 수정될 때 자동으로 시간과 사용자 정보를 기록한다.
@PreRemove
@PreRemove
는 JPA 엔티티의 생명주기(Lifecycle) 이벤트 중 하나로, 엔티티가 삭제되기 전에 호출되는 콜백 메서드를 정의할 때 사용한다.- 엔티티가
EntityManager
를 통해 제거되기 전에 자동으로 실행되며, 논리적 삭제를 구현할 때 자주 사용된다.
데이터베이스에서 실제로 삭제되기 전에 엔티티의 상태를 변경할 수 있다.
여기서는 삭제 타임스탬프(deletedAt
)를 기록하고, 논리적 삭제를 위해 isDeleted
플래그를 설정했다.
JpaConfig를 만들어서 @EnableJapAuditing 어노테이션을 추가해준다.
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
다음과 같이 엔티티별로 상속받아 사용한다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "p_store")
public class Store extends AuditingEntity {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(
name = "UUID",
strategy = "org.hibernate.id.UUIDGenerator"
)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
}
@CreatedBy, @LastModifiedBy는 어떻게 동작하지?
Spring Data JPA의 Auditing 기능과 Spring Security를 통해 현재 인증된 사용자의 정보를 자동으로 가져와 저장한다.
이 기능을 사용하려면 AuditorAware
인터페이스를 구현하고, 해당 구현체를 통해 현재 사용자 정보를 제공해야 한다.
그렇게 된다면 @CreatedBy
, @LastModifiedBy
어노테이션이 붙어 있는 필드들은 자동으로 현재 인증된 사용자의 정보로 채워진다.
AuditingEntityListener
는 기본적으로 @CreatedBy
, @LastModifiedBy
, @CreatedDate
, @LastModifiedDate
등의 어노테이션을 처리하는 역할을 한다.
AuditorAwareImpl.java
@Component
public class AuditorAwareImpl implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
// Spring Security를 사용하여 현재 인증된 사용자 정보를 가져옴
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
return Optional.of(((UserDetails) principal).getUsername());
} else {
return Optional.of(principal.toString());
}
}
}
deletedBy
이렇게 AuditorAware 인터페이스를 AuditorAwareImpl 구현체를 통해 구현하고 AuditingEntityListener를 등록해줌으로써 @CreatedBy
, @LastModifiedBy
는 해결이 되지만 deletedBy 필드에 해당하는 컬럼은 어떻게 넣어줘야 할까?
- AuditingEntityListener를 커스터마이징하여 삭제 시
deletedBy
필드를 설정하도록 구현하거나 - @PreRemove가 붙은 메서드에 시큐리티 관련 로직을 추가한다.
CustomAuditingEntityListener 구현 후 Listener로 등록
@Component
public class CustomAuditingEntityListener {
private final AuditorAware<String> auditorAware;
@Autowired
public CustomAuditingEntityListener(AuditorAware<String> auditorAware) {
this.auditorAware = auditorAware;
}
@PreRemove
public void setDeletedInfo(AuditingEntity entity) {
entity.setDeletedAt(LocalDateTime.now());
entity.setIsDeleted(true);
entity.setDeletedBy(auditorAware.getCurrentAuditor().orElse("Unknown"));
}
}
@EntityListeners({AuditingEntityListener.class, CustomAuditingEntityListener.class})
@MappedSuperclass
public abstract class AuditingEntity {
@CreatedDate
@Column(name="created_at", updatable = false, nullable = false)
private LocalDateTime createdAt;
@CreatedBy
@Column(name="created_by", updatable = false)
private String createdBy;
@LastModifiedDate
@Column(name="updated_at")
private LocalDateTime updatedAt;
@LastModifiedBy
@Column(name="updated_by")
private String updatedBy;
@Column(name="deleted_at")
private LocalDateTime deletedAt;
@Column(name="deleted_by")
private String deletedBy;
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public void setIsDeleted(Boolean isDeleted) {
this.isDeleted = isDeleted;
}
public void setDeletedBy(String deletedBy) {
this.deletedBy = deletedBy;
}
}
먼저 AuditingEntityListener가 수행된 후, CustomAuditingEntityListener가 수행된다.
@PreRemove 콜백 메서드에 사용자 정보를 가져오는 코드 추가
// 논리적 삭제, 엔티티가 삭제될 때 자동으로 수행
@PreRemove
public void onPreRemove() {
this.deletedAt = LocalDateTime.now();
this.isDeleted = true;
// 현재 사용자 정보를 SecurityContextHolder를 통해 가져와 설정
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
this.deletedBy = ((UserDetails) principal).getUsername(); // 사용자명 설정
} else {
this.deletedBy = principal.toString(); // 기본적으로 principal의 문자열 값을 사용
}
}
뭐가 더 나은 방식일지는 조금 더 고민이 필요할 것 같다.
이렇게 데이터 보존과 감사 로그 기능을 구현해봤다.